@malloy-publisher/server 0.0.227 → 0.0.229
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/dist/app/api-doc.yaml +84 -1
- package/dist/app/assets/EnvironmentPage-QOoHiVeJ.js +1 -0
- package/dist/app/assets/{HomePage-CXguJsXS.js → HomePage-C71GOfVW.js} +1 -1
- package/dist/app/assets/{LightMode-ZsshUznu.js → LightMode-CrgCAwLe.js} +1 -1
- package/dist/app/assets/MainPage-BG5__FN3.js +2 -0
- package/dist/app/assets/MaterializationsPage-DE6PnrDR.js +1 -0
- package/dist/app/assets/ModelPage-CcBjcbLm.js +1 -0
- package/dist/app/assets/PackagePage-JTy3ztkB.js +1 -0
- package/dist/app/assets/{RouteError-Chn7lL96.js → RouteError-Cymbp47a.js} +1 -1
- package/dist/app/assets/ThemeEditorPage-C_nMnHr8.js +1 -0
- package/dist/app/assets/WorkbookPage-CPQu-DQx.js +1 -0
- package/dist/app/assets/{core-vVgoO8IR.es-BD_THWs_.js → core-Coi3caGs.es-CSOmajHS.js} +10 -10
- package/dist/app/assets/{index-gEWxu09x.js → index-CM2qhQCI.js} +1 -1
- package/dist/app/assets/{index-D6YtyiJ0.js → index-CcuuST2X.js} +1 -1
- package/dist/app/assets/{index-OEjKNSYb.js → index-CkmABCAw.js} +388 -241
- package/dist/app/assets/{index-BioohWQj.js → index-DlWCXghy.js} +1 -1
- package/dist/app/assets/index-DxArlgRD.js +529 -0
- package/dist/app/index.html +1 -1
- package/dist/default-publisher.config.json +7 -7
- package/dist/instrumentation.mjs +1 -1
- package/dist/package_load_worker.mjs +1 -1
- package/dist/runtime/publisher.js +5 -0
- package/dist/server.mjs +7675 -899
- package/package.json +4 -2
- 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 +89 -3
- package/src/config.ts +102 -1
- package/src/controller/materialization.controller.spec.ts +9 -0
- package/src/controller/materialization.controller.ts +15 -0
- package/src/controller/package.controller.ts +62 -31
- package/src/default-publisher.config.json +7 -7
- package/src/materialization_metrics.ts +33 -0
- package/src/mcp/handler_utils.spec.ts +108 -0
- package/src/mcp/handler_utils.ts +98 -4
- package/src/mcp/server.protocol.spec.ts +58 -0
- package/src/mcp/server.ts +29 -1
- package/src/mcp/skills/build_skills_bundle.ts +29 -9
- package/src/mcp/skills/skills_bundle.json +1 -1
- package/src/mcp/skills/skills_bundle.spec.ts +31 -2
- package/src/mcp/tools/compile_tool.spec.ts +207 -0
- package/src/mcp/tools/compile_tool.ts +177 -0
- package/src/mcp/tools/execute_query_tool.spec.ts +143 -0
- package/src/mcp/tools/execute_query_tool.ts +37 -4
- package/src/mcp/tools/get_context_tool.ts +1 -1
- package/src/mcp/tools/reload_package_tool.spec.ts +232 -0
- package/src/mcp/tools/reload_package_tool.ts +158 -0
- package/src/query_param_utils.ts +11 -0
- package/src/runtime/publisher.js +5 -0
- package/src/server.ts +74 -14
- package/src/service/connection.spec.ts +8 -7
- package/src/service/cron_evaluator.spec.ts +79 -0
- package/src/service/cron_evaluator.ts +105 -0
- package/src/service/environment.ts +188 -19
- package/src/service/environment_store.spec.ts +372 -2
- package/src/service/environment_store.ts +149 -17
- package/src/service/environment_store_anchoring.spec.ts +107 -0
- package/src/service/manifest_loader.spec.ts +3 -1
- package/src/service/manifest_loader.ts +9 -5
- package/src/service/materialization_scheduler.spec.ts +260 -0
- package/src/service/materialization_scheduler.ts +304 -0
- package/src/service/materialization_service.spec.ts +167 -4
- package/src/service/materialization_service.ts +121 -8
- package/src/service/materialization_test_fixtures.ts +10 -1
- package/src/service/model.spec.ts +92 -0
- package/src/service/model.ts +58 -7
- package/src/service/package.ts +121 -14
- package/src/service/package_quote_bound_tables.spec.ts +171 -0
- package/src/service/package_reload_safety.spec.ts +193 -0
- package/src/service/persistence_policy.spec.ts +31 -0
- package/src/service/quoting.spec.ts +57 -1
- package/src/service/quoting.ts +33 -0
- package/src/storage/DatabaseInterface.ts +15 -0
- package/src/storage/duckdb/DuckDBRepository.ts +17 -0
- package/src/storage/duckdb/MaterializationRepository.spec.ts +154 -0
- package/src/storage/duckdb/MaterializationRepository.ts +56 -0
- package/tests/fixtures/persist-schedule-test/data/orders.csv +5 -0
- package/tests/fixtures/persist-schedule-test/persist_schedule_test.malloy +11 -0
- package/tests/fixtures/persist-schedule-test/publisher.json +7 -0
- package/tests/fixtures/persist-schedule-warn-test/data/orders.csv +5 -0
- package/tests/fixtures/persist-schedule-warn-test/persist_warn_test.malloy +11 -0
- package/tests/fixtures/persist-schedule-warn-test/publisher.json +6 -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/integration/materialization/environment_list.integration.spec.ts +133 -0
- package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +7 -2
- package/tests/integration/materialization/orchestrated_rebind.integration.spec.ts +150 -0
- package/tests/integration/materialization/schedule_edit.integration.spec.ts +126 -0
- package/tests/integration/materialization/scheduler.integration.spec.ts +200 -0
- package/tests/integration/materialization/scheduler_recovery.integration.spec.ts +172 -0
- package/tests/integration/materialization/scheduler_wiring.integration.spec.ts +143 -0
- package/tests/integration/mcp/mcp_execute_query_tool.integration.spec.ts +22 -22
- 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/dist/app/assets/EnvironmentPage-DvOJ7L_b.js +0 -1
- package/dist/app/assets/MainPage-BIe0VwBa.js +0 -2
- package/dist/app/assets/MaterializationsPage-BuZ6UJVx.js +0 -1
- package/dist/app/assets/ModelPage-DsPf-s8B.js +0 -1
- package/dist/app/assets/PackagePage-CEVNAKZa.js +0 -1
- package/dist/app/assets/ThemeEditorPage-DWC_FdNU.js +0 -1
- package/dist/app/assets/WorkbookPage-CGrsFz8p.js +0 -1
- package/dist/app/assets/index-DNUZpnaa.js +0 -527
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { MalloyError } from "@malloydata/malloy";
|
|
3
|
+
import { classifyToolError } from "./handler_utils";
|
|
4
|
+
import {
|
|
5
|
+
AccessDeniedError,
|
|
6
|
+
BadRequestError,
|
|
7
|
+
ModelCompilationError,
|
|
8
|
+
PackageNotFoundError,
|
|
9
|
+
ServiceUnavailableError,
|
|
10
|
+
} from "../errors";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* classifyToolError decides what an agent is told to do about a failure, so
|
|
14
|
+
* every branch is pinned by the advice it produces, not just by "an error came
|
|
15
|
+
* back". Asserting only that an error exists is what let every class funnel
|
|
16
|
+
* through the Malloy helper unnoticed.
|
|
17
|
+
*
|
|
18
|
+
* The routing is by error CLASS on purpose. getMalloyErrorDetails refines on
|
|
19
|
+
* message patterns the shipped engine does not emit, so "did it refine?" would
|
|
20
|
+
* send real compile errors to the internal branch.
|
|
21
|
+
*/
|
|
22
|
+
describe("classifyToolError", () => {
|
|
23
|
+
const advice = (e: unknown) =>
|
|
24
|
+
JSON.stringify(classifyToolError("op", "env/pkg", e).suggestions);
|
|
25
|
+
|
|
26
|
+
it("homes a missing package as not-found, not as Malloy", () => {
|
|
27
|
+
const details = classifyToolError(
|
|
28
|
+
"op",
|
|
29
|
+
"env/pkg",
|
|
30
|
+
new PackageNotFoundError("Package 'nope' not found"),
|
|
31
|
+
);
|
|
32
|
+
expect(details.message).toContain("Resource not found");
|
|
33
|
+
expect(JSON.stringify(details.suggestions)).not.toContain("Malloy file");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("homes back-pressure as retryable, not as Malloy", () => {
|
|
37
|
+
const details = classifyToolError(
|
|
38
|
+
"op",
|
|
39
|
+
"env/pkg",
|
|
40
|
+
new ServiceUnavailableError("Memory limit reached"),
|
|
41
|
+
);
|
|
42
|
+
expect(details.message).toContain("Memory limit reached");
|
|
43
|
+
expect(JSON.stringify(details.suggestions)).toContain("Retry");
|
|
44
|
+
expect(JSON.stringify(details.suggestions)).not.toContain("Malloy file");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("keeps Malloy advice for a raw engine error", () => {
|
|
48
|
+
// The regression guard for executeQuery. A bad query throws MalloyError,
|
|
49
|
+
// NOT ModelCompilationError: the engine's error reaches the tool's catch
|
|
50
|
+
// unwrapped. Route it by the internal branch and a syntax error tells the
|
|
51
|
+
// agent an "unexpected internal error occurred", which is worse than the
|
|
52
|
+
// bug this classifier was written to fix.
|
|
53
|
+
const details = classifyToolError(
|
|
54
|
+
"executeQuery",
|
|
55
|
+
"env/pkg/m.malloy",
|
|
56
|
+
new MalloyError("unexpected '@'", []),
|
|
57
|
+
);
|
|
58
|
+
expect(details.message).not.toContain("unexpected internal error");
|
|
59
|
+
expect(advice(new MalloyError("unexpected '@'", []))).toContain("Malloy");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("keeps Malloy advice for a wrapped compile error", () => {
|
|
63
|
+
// What a failed reload throws. #890's promise is that a failed reload
|
|
64
|
+
// returns the compile errors, so this must not read as an internal fault.
|
|
65
|
+
const details = classifyToolError(
|
|
66
|
+
"reloadPackage",
|
|
67
|
+
"env/pkg",
|
|
68
|
+
new ModelCompilationError({
|
|
69
|
+
message: "Error(s) compiling model: unexpected '@'",
|
|
70
|
+
}),
|
|
71
|
+
);
|
|
72
|
+
expect(details.message).not.toContain("unexpected internal error");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("keeps Malloy advice for an authorize denial and a malformed request", () => {
|
|
76
|
+
expect(
|
|
77
|
+
classifyToolError(
|
|
78
|
+
"op",
|
|
79
|
+
"env/pkg",
|
|
80
|
+
new AccessDeniedError('Access denied for source "orders"'),
|
|
81
|
+
).message,
|
|
82
|
+
).not.toContain("unexpected internal error");
|
|
83
|
+
expect(
|
|
84
|
+
classifyToolError(
|
|
85
|
+
"op",
|
|
86
|
+
"env/pkg",
|
|
87
|
+
new BadRequestError("Invalid query request."),
|
|
88
|
+
).message,
|
|
89
|
+
).not.toContain("unexpected internal error");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("reports anything else as internal rather than blaming the Malloy", () => {
|
|
93
|
+
// A bug in our own code, a filesystem failure, or a worker crash. Telling
|
|
94
|
+
// the agent to check its syntax sends it to edit a model that is fine.
|
|
95
|
+
for (const error of [
|
|
96
|
+
new TypeError("cannot read properties of undefined"),
|
|
97
|
+
Object.assign(new Error("EACCES: permission denied"), {
|
|
98
|
+
code: "EACCES",
|
|
99
|
+
}),
|
|
100
|
+
]) {
|
|
101
|
+
const details = classifyToolError("op", "env/pkg", error);
|
|
102
|
+
expect(details.message).toContain("unexpected internal error");
|
|
103
|
+
expect(JSON.stringify(details.suggestions)).not.toContain(
|
|
104
|
+
"Malloy file",
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
});
|
package/src/mcp/handler_utils.ts
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
|
+
import { MalloyError } from "@malloydata/malloy";
|
|
1
2
|
import { EnvironmentStore } from "../service/environment_store";
|
|
2
3
|
import {
|
|
3
4
|
AccessDeniedError,
|
|
5
|
+
BadRequestError,
|
|
4
6
|
PackageNotFoundError,
|
|
5
7
|
ModelNotFoundError,
|
|
6
8
|
ModelCompilationError,
|
|
7
9
|
EnvironmentNotFoundError,
|
|
10
|
+
NotQueryableError,
|
|
11
|
+
PayloadTooLargeError,
|
|
12
|
+
QueryTimeoutError,
|
|
8
13
|
ServiceUnavailableError,
|
|
9
14
|
} from "../errors";
|
|
10
15
|
import {
|
|
@@ -16,6 +21,98 @@ import {
|
|
|
16
21
|
import type { Model } from "../service/model";
|
|
17
22
|
import { logger } from "../logger";
|
|
18
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Remediation for a ServiceUnavailableError, shared by every caller that homes
|
|
26
|
+
* one. The error covers both the memory governor and the concurrency slots, so
|
|
27
|
+
* the wording names both rather than only memory.
|
|
28
|
+
*/
|
|
29
|
+
export const BACK_PRESSURE_SUGGESTIONS: readonly string[] = [
|
|
30
|
+
"Retry once the publisher is below its configured memory or concurrency limit.",
|
|
31
|
+
"If this persists, raise the limit or scale up the pod.",
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Turns a thrown error into agent-facing ErrorDetails, homed by error class.
|
|
36
|
+
*
|
|
37
|
+
* Without this, a tool that funnels every failure through getMalloyErrorDetails
|
|
38
|
+
* dresses each one up as a Malloy language problem: a missing package tells the
|
|
39
|
+
* agent to check its Malloy syntax, and a back-pressure rejection sends it
|
|
40
|
+
* hunting for a typo instead of retrying.
|
|
41
|
+
*
|
|
42
|
+
* Only the classes that really are about the caller's Malloy or request reach
|
|
43
|
+
* the Malloy advice; anything else is reported as internal rather than blamed
|
|
44
|
+
* on the model. Routing by class and not by message is deliberate:
|
|
45
|
+
* getMalloyErrorDetails refines on message patterns that the shipped engine
|
|
46
|
+
* does not emit (its syntax pattern wants "no viable alternative at input"
|
|
47
|
+
* where a real error says "unexpected '@'"), so "did it refine?" would send
|
|
48
|
+
* genuine compile errors to the internal branch.
|
|
49
|
+
*
|
|
50
|
+
* @param operation The operation that failed (e.g. 'compile', 'reloadPackage').
|
|
51
|
+
* @param identifier The resource it failed on (e.g. 'env/package').
|
|
52
|
+
*/
|
|
53
|
+
export function classifyToolError(
|
|
54
|
+
operation: string,
|
|
55
|
+
identifier: string,
|
|
56
|
+
error: unknown,
|
|
57
|
+
): ErrorDetails {
|
|
58
|
+
if (
|
|
59
|
+
error instanceof EnvironmentNotFoundError ||
|
|
60
|
+
error instanceof PackageNotFoundError ||
|
|
61
|
+
error instanceof ModelNotFoundError ||
|
|
62
|
+
// A query-boundary denial is a deliberate 404, and reporting it as a
|
|
63
|
+
// server fault would tell the caller to contact support about a boundary
|
|
64
|
+
// that is working. getNotFoundError also drops the source name this class
|
|
65
|
+
// is careful not to confirm.
|
|
66
|
+
error instanceof NotQueryableError
|
|
67
|
+
) {
|
|
68
|
+
return getNotFoundError(identifier);
|
|
69
|
+
}
|
|
70
|
+
if (error instanceof ServiceUnavailableError) {
|
|
71
|
+
// Back-pressure: surface the server's own message so the caller knows to
|
|
72
|
+
// retry rather than to go edit its Malloy.
|
|
73
|
+
return {
|
|
74
|
+
message: error.message,
|
|
75
|
+
suggestions: [...BACK_PRESSURE_SUGGESTIONS],
|
|
76
|
+
} satisfies ErrorDetails;
|
|
77
|
+
}
|
|
78
|
+
if (
|
|
79
|
+
error instanceof QueryTimeoutError ||
|
|
80
|
+
error instanceof PayloadTooLargeError
|
|
81
|
+
) {
|
|
82
|
+
// The query was too slow or too big. Both classes already carry their own
|
|
83
|
+
// "refine the query" remediation in the message, so the suggestions say
|
|
84
|
+
// the part the message leaves out and the internal branch got backwards:
|
|
85
|
+
// this is not transient, and retrying it unchanged fails identically.
|
|
86
|
+
// That distinction is the whole reason these are 504/413 and not the
|
|
87
|
+
// retryable 503 (see the QueryTimeoutError docstring in errors.ts).
|
|
88
|
+
return {
|
|
89
|
+
message: error.message,
|
|
90
|
+
suggestions: [
|
|
91
|
+
"This is not transient. The same query will fail the same way, so change the query rather than retrying it.",
|
|
92
|
+
"If it is already minimal, raise the configured cap named in the message.",
|
|
93
|
+
],
|
|
94
|
+
} satisfies ErrorDetails;
|
|
95
|
+
}
|
|
96
|
+
if (
|
|
97
|
+
// A raw engine error: what a bad query throws (syntax, undefined name,
|
|
98
|
+
// field not found). executeQuery's catch sees these, so they must keep
|
|
99
|
+
// the Malloy advice.
|
|
100
|
+
error instanceof MalloyError ||
|
|
101
|
+
// The same thing wrapped: what a failed compile or reload throws.
|
|
102
|
+
error instanceof ModelCompilationError ||
|
|
103
|
+
// An #(authorize) denial, whose message getMalloyErrorDetails recognizes.
|
|
104
|
+
error instanceof AccessDeniedError ||
|
|
105
|
+
// A malformed request is the caller's to fix, not ours to retry.
|
|
106
|
+
error instanceof BadRequestError
|
|
107
|
+
) {
|
|
108
|
+
return getMalloyErrorDetails(operation, identifier, error);
|
|
109
|
+
}
|
|
110
|
+
// Everything else (a filesystem EACCES, a TypeError, a worker crash, a
|
|
111
|
+
// timeout) is not the caller's Malloy. Telling them to check their syntax
|
|
112
|
+
// sends them to edit a model that is fine.
|
|
113
|
+
return getInternalError(operation, error);
|
|
114
|
+
}
|
|
115
|
+
|
|
19
116
|
/**
|
|
20
117
|
* Fetches and validates the Package and Model instances needed for query execution.
|
|
21
118
|
* Handles errors related to package/model access and initial compilation.
|
|
@@ -82,10 +179,7 @@ export async function getModelForQuery(
|
|
|
82
179
|
// server's own message so the MCP caller knows to retry.
|
|
83
180
|
errorDetails = {
|
|
84
181
|
message: error.message,
|
|
85
|
-
suggestions: [
|
|
86
|
-
"Retry after the publisher's memory usage drops below the configured low-water mark.",
|
|
87
|
-
"If this happens repeatedly, raise PUBLISHER_MAX_MEMORY_BYTES or scale up the pod.",
|
|
88
|
-
],
|
|
182
|
+
suggestions: [...BACK_PRESSURE_SUGGESTIONS],
|
|
89
183
|
} satisfies ErrorDetails;
|
|
90
184
|
} else {
|
|
91
185
|
// Unexpected error during setup
|
|
@@ -2,6 +2,7 @@ import { beforeAll, describe, expect, it } from "bun:test";
|
|
|
2
2
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
3
3
|
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
4
4
|
import { initializeMcpServer } from "./server";
|
|
5
|
+
import { RELOAD_FAILURE_IS_SAFE } from "./tools/reload_package_tool";
|
|
5
6
|
import type { EnvironmentStore } from "../service/environment_store";
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -37,6 +38,8 @@ describe("MCP server over the MCP protocol (in-memory)", () => {
|
|
|
37
38
|
expect(names.has("malloy_getContext")).toBe(true);
|
|
38
39
|
expect(names.has("malloy_searchDocs")).toBe(true);
|
|
39
40
|
expect(names.has("malloy_executeQuery")).toBe(true);
|
|
41
|
+
expect(names.has("malloy_compile")).toBe(true);
|
|
42
|
+
expect(names.has("malloy_reloadPackage")).toBe(true);
|
|
40
43
|
});
|
|
41
44
|
|
|
42
45
|
it("exposes the skill set as dual-channel prompts", async () => {
|
|
@@ -45,6 +48,17 @@ describe("MCP server over the MCP protocol (in-memory)", () => {
|
|
|
45
48
|
expect(prompts.some((p) => p.name === "malloy-analysis")).toBe(true);
|
|
46
49
|
});
|
|
47
50
|
|
|
51
|
+
it("delivers orientation instructions to the connecting client", () => {
|
|
52
|
+
const instructions = client.getInstructions();
|
|
53
|
+
expect(instructions).toBeDefined();
|
|
54
|
+
expect(instructions).toContain("malloy_getContext");
|
|
55
|
+
// Pin the shared fragment, not just that instructions exist. The whole
|
|
56
|
+
// point of exporting it is that this surface and the tool description
|
|
57
|
+
// cannot drift, and they have drifted twice. Without this, deleting the
|
|
58
|
+
// interpolation leaves the suite green and the drift comes back.
|
|
59
|
+
expect(instructions).toContain(RELOAD_FAILURE_IS_SAFE);
|
|
60
|
+
});
|
|
61
|
+
|
|
48
62
|
it("malloy_searchDocs returns relevant docs over the protocol", async () => {
|
|
49
63
|
const res = await client.callTool({
|
|
50
64
|
name: "malloy_searchDocs",
|
|
@@ -72,6 +86,50 @@ describe("MCP server over the MCP protocol (in-memory)", () => {
|
|
|
72
86
|
expect(res.isError).toBe(true);
|
|
73
87
|
});
|
|
74
88
|
|
|
89
|
+
/**
|
|
90
|
+
* Assert the tool's OWN error payload, not just isError. The SDK sets
|
|
91
|
+
* isError for any uncaught throw, so `expect(res.isError).toBe(true)` passes
|
|
92
|
+
* even with the tool's catch deleted entirely. Only these tools emit a
|
|
93
|
+
* resource block carrying structured JSON; a raw throw yields the SDK's text
|
|
94
|
+
* content instead, so this shape is what actually pins the catch.
|
|
95
|
+
*/
|
|
96
|
+
function expectToolErrorPayload(result: unknown): {
|
|
97
|
+
error: string;
|
|
98
|
+
suggestions: string[];
|
|
99
|
+
} {
|
|
100
|
+
const res = result as {
|
|
101
|
+
isError?: boolean;
|
|
102
|
+
content?: Array<{ type?: string; resource?: { text?: string } }>;
|
|
103
|
+
};
|
|
104
|
+
expect(res.isError).toBe(true);
|
|
105
|
+
expect(res.content?.[0]?.type).toBe("resource");
|
|
106
|
+
const payload = JSON.parse(res.content?.[0]?.resource?.text ?? "{}");
|
|
107
|
+
expect(typeof payload.error).toBe("string");
|
|
108
|
+
expect(Array.isArray(payload.suggestions)).toBe(true);
|
|
109
|
+
return payload;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
it("malloy_compile returns its own error payload over the protocol", async () => {
|
|
113
|
+
const res = await client.callTool({
|
|
114
|
+
name: "malloy_compile",
|
|
115
|
+
arguments: {
|
|
116
|
+
environmentName: "nope",
|
|
117
|
+
packageName: "nope",
|
|
118
|
+
modelPath: "x.malloy",
|
|
119
|
+
source: "run: x -> { aggregate: c is count() }",
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
expect(expectToolErrorPayload(res).error).toContain("nope");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("malloy_reloadPackage returns its own error payload over the protocol", async () => {
|
|
126
|
+
const res = await client.callTool({
|
|
127
|
+
name: "malloy_reloadPackage",
|
|
128
|
+
arguments: { environmentName: "nope", packageName: "nope" },
|
|
129
|
+
});
|
|
130
|
+
expect(expectToolErrorPayload(res).error).toContain("nope");
|
|
131
|
+
});
|
|
132
|
+
|
|
75
133
|
it("a skill prompt returns its body", async () => {
|
|
76
134
|
const res = await client.getPrompt({ name: "malloy-analysis" });
|
|
77
135
|
const content = res.messages[0].content as { text?: string };
|
package/src/mcp/server.ts
CHANGED
|
@@ -2,9 +2,14 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
2
2
|
import { EnvironmentStore } from "../service/environment_store";
|
|
3
3
|
|
|
4
4
|
import { formatDuration, logger } from "../logger";
|
|
5
|
+
import { registerCompileTool } from "./tools/compile_tool";
|
|
5
6
|
import { registerDocsSearchTool } from "./tools/docs_search_tool";
|
|
6
7
|
import { registerExecuteQueryTool } from "./tools/execute_query_tool";
|
|
7
8
|
import { registerGetContextTool } from "./tools/get_context_tool";
|
|
9
|
+
import {
|
|
10
|
+
registerReloadPackageTool,
|
|
11
|
+
RELOAD_FAILURE_IS_SAFE,
|
|
12
|
+
} from "./tools/reload_package_tool";
|
|
8
13
|
import skillsBundle from "./skills/skills_bundle.json";
|
|
9
14
|
|
|
10
15
|
export const testServerInfo = {
|
|
@@ -22,17 +27,40 @@ const AGENT_SKILLS = (
|
|
|
22
27
|
}
|
|
23
28
|
).skills;
|
|
24
29
|
|
|
30
|
+
// Orientation delivered to any connecting MCP client via the initialize
|
|
31
|
+
// response (the on-the-wire analog of AGENTS.md), so an agent that reached the
|
|
32
|
+
// server over npx without cloning the repo still knows how to work. Kept
|
|
33
|
+
// workflow-focused rather than a tool catalog, which the client already gets
|
|
34
|
+
// from listTools, so it does not drift as the tool set changes.
|
|
35
|
+
// SECURITY: this is delivered pre-authorization to any connecting client, so it
|
|
36
|
+
// must stay STATIC. It may interpolate module-scope string LITERALS (see
|
|
37
|
+
// RELOAD_FAILURE_IS_SAFE, shared with the tool description so the two cannot
|
|
38
|
+
// drift). Not "constants": `const x = process.env.FOO` is a module-scope
|
|
39
|
+
// constant and would put deployment config into an unauthenticated string. It
|
|
40
|
+
// must never interpolate environment, package, connection, or request data.
|
|
41
|
+
const MCP_INSTRUCTIONS = `Malloy Publisher serves one or more Malloy semantic-model packages, so you can discover what data exists and answer questions against it, grounded in the names the model actually defines.
|
|
42
|
+
|
|
43
|
+
Start with malloy_getContext. Call it with no arguments to list the environments (each with its packages), with an environment to list its packages, with a package to list its sources, and with a package plus a plain-English question to get the sources, views, and fields most relevant to it. Use the names it returns verbatim and do not guess. Then run a query with malloy_executeQuery. To change a model: validate the edit with malloy_compile, save it, then call malloy_reloadPackage so the new sources and views become queryable by name without restarting the server. ${RELOAD_FAILURE_IS_SAFE}
|
|
44
|
+
|
|
45
|
+
Task-specific guidance is served as prompts you can fetch by name: malloy-getting-started to begin, malloy-modeling to build or change a model, malloy-analysis to explore and answer questions, and malloy-review to check correctness.
|
|
46
|
+
|
|
47
|
+
Results and any charts render in the Publisher web UI on the REST port (4000 by default).`;
|
|
48
|
+
|
|
25
49
|
export function initializeMcpServer(
|
|
26
50
|
environmentStore: EnvironmentStore,
|
|
27
51
|
): McpServer {
|
|
28
52
|
logger.info("[MCP Init] Starting initializeMcpServer...");
|
|
29
53
|
const startTime = performance.now();
|
|
30
54
|
|
|
31
|
-
const mcpServer = new McpServer(testServerInfo
|
|
55
|
+
const mcpServer = new McpServer(testServerInfo, {
|
|
56
|
+
instructions: MCP_INSTRUCTIONS,
|
|
57
|
+
});
|
|
32
58
|
|
|
33
59
|
registerExecuteQueryTool(mcpServer, environmentStore);
|
|
34
60
|
registerGetContextTool(mcpServer, environmentStore);
|
|
35
61
|
registerDocsSearchTool(mcpServer, environmentStore);
|
|
62
|
+
registerCompileTool(mcpServer, environmentStore);
|
|
63
|
+
registerReloadPackageTool(mcpServer, environmentStore);
|
|
36
64
|
|
|
37
65
|
// Dual-channel: also expose each skill as an MCP prompt, so hosts that ingest
|
|
38
66
|
// MCP but do not load skill files (e.g. Codex, ChatGPT, Cursor) can pull the
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import * as fs from "fs";
|
|
14
14
|
import * as path from "path";
|
|
15
15
|
|
|
16
|
-
interface SkillEntry {
|
|
16
|
+
export interface SkillEntry {
|
|
17
17
|
name: string;
|
|
18
18
|
description: string;
|
|
19
19
|
body: string;
|
|
@@ -36,6 +36,33 @@ export function parseSkill(md: string, dirName: string): SkillEntry {
|
|
|
36
36
|
return { name, description, body };
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* `credible-*` skills are Credible-platform-specific and never ship (see
|
|
41
|
+
* skills/README.md). They can legitimately sit in skills/ uncommitted: the
|
|
42
|
+
* directory is a gitignored local install target (`skills/credible-*` in
|
|
43
|
+
* .gitignore), so the bundle must skip them or a regeneration on a machine
|
|
44
|
+
* with Credible installed would bake them into the committed bundle, and the
|
|
45
|
+
* regenerate-and-compare spec would fail on a clean checkout.
|
|
46
|
+
*
|
|
47
|
+
* The canonical predicate lives in packages/skills/scripts/exclusions.ts;
|
|
48
|
+
* duplicated here because the server does not depend on that package.
|
|
49
|
+
*/
|
|
50
|
+
export function isCredible(dirName: string): boolean {
|
|
51
|
+
return dirName.toLowerCase().startsWith("credible-");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Read every skill under a skills directory, in the bundle's own order. */
|
|
55
|
+
export function buildSkills(skillsDir: string): SkillEntry[] {
|
|
56
|
+
const skills: SkillEntry[] = [];
|
|
57
|
+
for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) {
|
|
58
|
+
if (!entry.isDirectory() || isCredible(entry.name)) continue;
|
|
59
|
+
const skillFile = path.join(skillsDir, entry.name, "SKILL.md");
|
|
60
|
+
if (!fs.existsSync(skillFile)) continue;
|
|
61
|
+
skills.push(parseSkill(fs.readFileSync(skillFile, "utf8"), entry.name));
|
|
62
|
+
}
|
|
63
|
+
return skills.sort((a, b) => a.name.localeCompare(b.name));
|
|
64
|
+
}
|
|
65
|
+
|
|
39
66
|
function main(): void {
|
|
40
67
|
const skillsDir = process.argv[2];
|
|
41
68
|
const outFile =
|
|
@@ -47,14 +74,7 @@ function main(): void {
|
|
|
47
74
|
process.exit(1);
|
|
48
75
|
}
|
|
49
76
|
|
|
50
|
-
const skills
|
|
51
|
-
for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) {
|
|
52
|
-
if (!entry.isDirectory()) continue;
|
|
53
|
-
const skillFile = path.join(skillsDir, entry.name, "SKILL.md");
|
|
54
|
-
if (!fs.existsSync(skillFile)) continue;
|
|
55
|
-
skills.push(parseSkill(fs.readFileSync(skillFile, "utf8"), entry.name));
|
|
56
|
-
}
|
|
57
|
-
skills.sort((a, b) => a.name.localeCompare(b.name));
|
|
77
|
+
const skills = buildSkills(skillsDir);
|
|
58
78
|
|
|
59
79
|
fs.writeFileSync(outFile, JSON.stringify({ skills }));
|
|
60
80
|
console.log(`Wrote ${skills.length} skills to ${outFile}`);
|