@malloy-publisher/server 0.0.227 → 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.
Files changed (53) hide show
  1. package/README.docker.md +8 -8
  2. package/dist/app/assets/{EnvironmentPage-DvOJ7L_b.js → EnvironmentPage-EW2lbGvb.js} +1 -1
  3. package/dist/app/assets/{HomePage-CXguJsXS.js → HomePage-Bkwc9Woc.js} +1 -1
  4. package/dist/app/assets/{LightMode-ZsshUznu.js → LightMode-Bum_KBpN.js} +1 -1
  5. package/dist/app/assets/{MainPage-BIe0VwBa.js → MainPage-oiEy7TNM.js} +1 -1
  6. package/dist/app/assets/{MaterializationsPage-BuZ6UJVx.js → MaterializationsPage-C_VJsTgU.js} +1 -1
  7. package/dist/app/assets/{ModelPage-DsPf-s8B.js → ModelPage-z8REqAmk.js} +1 -1
  8. package/dist/app/assets/{PackagePage-CEVNAKZa.js → PackagePage-C2Vtt1Ln.js} +1 -1
  9. package/dist/app/assets/{RouteError-Chn7lL96.js → RouteError-DmJLpLXm.js} +1 -1
  10. package/dist/app/assets/{ThemeEditorPage-DWC_FdNU.js → ThemeEditorPage-BywFjC7A.js} +1 -1
  11. package/dist/app/assets/{WorkbookPage-CGrsFz8p.js → WorkbookPage-DCMizDMR.js} +1 -1
  12. package/dist/app/assets/{core-vVgoO8IR.es-BD_THWs_.js → core-CEDZMHV1.es-_yGzNgNH.js} +1 -1
  13. package/dist/app/assets/{index-D6YtyiJ0.js → index-CE9xhdra.js} +1 -1
  14. package/dist/app/assets/{index-BioohWQj.js → index-CdmFub34.js} +1 -1
  15. package/dist/app/assets/{index-gEWxu09x.js → index-DDMrjIT3.js} +1 -1
  16. package/dist/app/assets/{index-DNUZpnaa.js → index-EqslXZ44.js} +4 -4
  17. package/dist/app/index.html +1 -1
  18. package/dist/default-publisher.config.json +7 -7
  19. package/dist/runtime/publisher.js +5 -0
  20. package/dist/server.mjs +841 -963
  21. package/package.json +1 -1
  22. package/publisher.config.example.bigquery.json +7 -7
  23. package/publisher.config.example.duckdb.json +7 -7
  24. package/publisher.config.json +7 -11
  25. package/src/config.spec.ts +2 -2
  26. package/src/controller/package.controller.ts +62 -31
  27. package/src/default-publisher.config.json +7 -7
  28. package/src/mcp/handler_utils.spec.ts +108 -0
  29. package/src/mcp/handler_utils.ts +98 -4
  30. package/src/mcp/server.protocol.spec.ts +58 -0
  31. package/src/mcp/server.ts +29 -1
  32. package/src/mcp/skills/skills_bundle.json +1 -1
  33. package/src/mcp/tools/compile_tool.spec.ts +207 -0
  34. package/src/mcp/tools/compile_tool.ts +177 -0
  35. package/src/mcp/tools/execute_query_tool.spec.ts +143 -0
  36. package/src/mcp/tools/execute_query_tool.ts +37 -4
  37. package/src/mcp/tools/get_context_tool.ts +1 -1
  38. package/src/mcp/tools/reload_package_tool.spec.ts +232 -0
  39. package/src/mcp/tools/reload_package_tool.ts +158 -0
  40. package/src/runtime/publisher.js +5 -0
  41. package/src/server.ts +7 -5
  42. package/src/service/environment.ts +70 -6
  43. package/src/service/model.spec.ts +92 -0
  44. package/src/service/model.ts +58 -7
  45. package/src/service/package.ts +31 -13
  46. package/src/service/package_reload_safety.spec.ts +193 -0
  47. package/tests/fixtures/query-givens/data/orders.csv +7 -0
  48. package/tests/fixtures/query-givens/model.malloy +34 -0
  49. package/tests/fixtures/query-givens/publisher.json +5 -0
  50. package/tests/integration/mcp/mcp_execute_query_tool.integration.spec.ts +22 -22
  51. package/tests/integration/query_givens/query_givens.integration.spec.ts +146 -0
  52. package/tests/integration/query_givens/query_givens_authorize.integration.spec.ts +121 -0
  53. package/tests/integration/sdk_givens/sdk_givens.integration.spec.ts +110 -0
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@malloy-publisher/server",
3
3
  "description": "Malloy Publisher Server",
4
- "version": "0.0.227",
4
+ "version": "0.0.228",
5
5
  "main": "dist/server.mjs",
6
6
  "bin": {
7
7
  "malloy-publisher": "dist/server.mjs"
@@ -2,19 +2,19 @@
2
2
  "frozenConfig": false,
3
3
  "environments": [
4
4
  {
5
- "name": "malloy-samples",
5
+ "name": "examples",
6
6
  "packages": [
7
7
  {
8
- "name": "ecommerce",
9
- "location": "https://github.com/credibledata/malloy-samples/tree/main/ecommerce"
8
+ "name": "storefront",
9
+ "location": "https://github.com/malloydata/publisher/tree/main/examples/storefront"
10
10
  },
11
11
  {
12
- "name": "imdb",
13
- "location": "https://github.com/credibledata/malloy-samples/tree/main/imdb"
12
+ "name": "governed-analytics",
13
+ "location": "https://github.com/malloydata/publisher/tree/main/examples/governed-analytics"
14
14
  },
15
15
  {
16
- "name": "faa",
17
- "location": "https://github.com/credibledata/malloy-samples/tree/main/faa"
16
+ "name": "html-data-app",
17
+ "location": "https://github.com/malloydata/publisher/tree/main/examples/html-data-app"
18
18
  },
19
19
  {
20
20
  "name": "bigquery-hackernews",
@@ -2,19 +2,19 @@
2
2
  "frozenConfig": false,
3
3
  "environments": [
4
4
  {
5
- "name": "malloy-samples",
5
+ "name": "examples",
6
6
  "packages": [
7
7
  {
8
- "name": "ecommerce",
9
- "location": "https://github.com/credibledata/malloy-samples/tree/main/ecommerce"
8
+ "name": "storefront",
9
+ "location": "https://github.com/malloydata/publisher/tree/main/examples/storefront"
10
10
  },
11
11
  {
12
- "name": "imdb",
13
- "location": "https://github.com/credibledata/malloy-samples/tree/main/imdb"
12
+ "name": "governed-analytics",
13
+ "location": "https://github.com/malloydata/publisher/tree/main/examples/governed-analytics"
14
14
  },
15
15
  {
16
- "name": "faa",
17
- "location": "https://github.com/credibledata/malloy-samples/tree/main/faa"
16
+ "name": "html-data-app",
17
+ "location": "https://github.com/malloydata/publisher/tree/main/examples/html-data-app"
18
18
  }
19
19
  ],
20
20
  "connections": []
@@ -2,23 +2,19 @@
2
2
  "frozenConfig": false,
3
3
  "environments": [
4
4
  {
5
- "name": "malloy-samples",
5
+ "name": "examples",
6
6
  "packages": [
7
7
  {
8
- "name": "ecommerce",
9
- "location": "https://github.com/credibledata/malloy-samples/tree/main/ecommerce"
8
+ "name": "storefront",
9
+ "location": "../../examples/storefront"
10
10
  },
11
11
  {
12
- "name": "imdb",
13
- "location": "https://github.com/credibledata/malloy-samples/tree/main/imdb"
12
+ "name": "governed-analytics",
13
+ "location": "../../examples/governed-analytics"
14
14
  },
15
15
  {
16
- "name": "faa",
17
- "location": "https://github.com/credibledata/malloy-samples/tree/main/faa"
18
- },
19
- {
20
- "name": "faa-givens-demo",
21
- "location": "https://github.com/credibledata/malloy-samples/tree/main/faa-givens-demo"
16
+ "name": "html-data-app",
17
+ "location": "../../examples/html-data-app"
22
18
  }
23
19
  ],
24
20
  "connections": []
@@ -1040,8 +1040,8 @@ describe("Config path resolution (--config and bundled default)", () => {
1040
1040
 
1041
1041
  expect(result.environments.length).toBeGreaterThan(0);
1042
1042
  const env = result.environments[0];
1043
- expect(env.name).toBe("malloy-samples");
1044
- expect(env.packages.some((p) => p.name === "ecommerce")).toBe(true);
1043
+ expect(env.name).toBe("examples");
1044
+ expect(env.packages.some((p) => p.name === "storefront")).toBe(true);
1045
1045
  expect(env.packages.some((p) => p.name === "bigquery-hackernews")).toBe(
1046
1046
  false,
1047
1047
  );
@@ -5,6 +5,13 @@ import { EnvironmentStore } from "../service/environment_store";
5
5
 
6
6
  type ApiPackage = components["schemas"]["Package"];
7
7
 
8
+ /**
9
+ * Which path a reload took. `in-place` recompiles the tree already on disk and
10
+ * leaves it alone; `reinstalled` re-fetches from the package's install location,
11
+ * which overwrites on-disk edits.
12
+ */
13
+ export type PackageReloadMode = "in-place" | "reinstalled";
14
+
8
15
  /**
9
16
  * Everything that is strict-at-publish, joined into one 400 message (or
10
17
  * undefined when the package is publishable): invalid explores entries plus
@@ -50,45 +57,69 @@ export class PackageController {
50
57
  packageName: string,
51
58
  reload: boolean,
52
59
  ): Promise<ApiPackage> {
60
+ if (reload) {
61
+ return (await this.reloadPackage(environmentName, packageName))
62
+ .metadata;
63
+ }
64
+
53
65
  const environment = await this.environmentStore.getEnvironment(
54
66
  environmentName,
55
67
  false,
56
68
  );
57
-
58
- if (reload) {
59
- // Resolve the package's source location from the currently-cached
60
- // metadata WITHOUT triggering a stale-state reload. If a `location`
61
- // is set, route the reload through `installPackage` so that
62
- // download-then-load happens atomically; otherwise fall back to an
63
- // in-place reload of the existing on-disk content.
64
- let location: string | undefined;
65
- try {
66
- const cached = await environment.getPackage(packageName, false);
67
- location = cached.getPackageMetadata().location;
68
- } catch {
69
- // Not previously loaded — nothing to reinstall from.
70
- }
71
- if (location) {
72
- const reinstalled = await environment.installPackage(
73
- packageName,
74
- (stagingPath) =>
75
- this.downloadInto(
76
- environmentName,
77
- packageName,
78
- location,
79
- stagingPath,
80
- ),
81
- );
82
- return reinstalled.getPackageMetadata();
83
- }
84
- const _package = await environment.getPackage(packageName, true);
85
- return _package.getPackageMetadata();
86
- }
87
-
88
69
  const _package = await environment.getPackage(packageName, false);
89
70
  return _package.getPackageMetadata();
90
71
  }
91
72
 
73
+ /**
74
+ * Reload a package and report WHICH path ran. The two are not equivalent to
75
+ * anyone holding on-disk edits: an in-place reload recompiles the tree that
76
+ * is already there, while a reinstall re-fetches from the package's install
77
+ * location and overwrites it. A caller that surfaces the reload to a user
78
+ * needs to be able to say which happened, so it is returned rather than
79
+ * inferred. `getPackage` keeps returning bare metadata, so the REST contract
80
+ * is unchanged.
81
+ */
82
+ public async reloadPackage(
83
+ environmentName: string,
84
+ packageName: string,
85
+ ): Promise<{ metadata: ApiPackage; mode: PackageReloadMode }> {
86
+ const environment = await this.environmentStore.getEnvironment(
87
+ environmentName,
88
+ false,
89
+ );
90
+
91
+ // Resolve the package's source location from the currently-cached
92
+ // metadata WITHOUT triggering a stale-state reload. If a `location`
93
+ // is set, route the reload through `installPackage` so that
94
+ // download-then-load happens atomically; otherwise fall back to an
95
+ // in-place reload of the existing on-disk content.
96
+ let location: string | undefined;
97
+ try {
98
+ const cached = await environment.getPackage(packageName, false);
99
+ location = cached.getPackageMetadata().location;
100
+ } catch {
101
+ // Not previously loaded, so there is nothing to reinstall from.
102
+ }
103
+ if (location) {
104
+ const reinstalled = await environment.installPackage(
105
+ packageName,
106
+ (stagingPath) =>
107
+ this.downloadInto(
108
+ environmentName,
109
+ packageName,
110
+ location,
111
+ stagingPath,
112
+ ),
113
+ );
114
+ return {
115
+ metadata: reinstalled.getPackageMetadata(),
116
+ mode: "reinstalled",
117
+ };
118
+ }
119
+ const _package = await environment.getPackage(packageName, true);
120
+ return { metadata: _package.getPackageMetadata(), mode: "in-place" };
121
+ }
122
+
92
123
  async addPackage(environmentName: string, body: ApiPackage) {
93
124
  if (this.environmentStore.publisherConfigIsFrozen) {
94
125
  throw new FrozenConfigError();
@@ -2,19 +2,19 @@
2
2
  "frozenConfig": false,
3
3
  "environments": [
4
4
  {
5
- "name": "malloy-samples",
5
+ "name": "examples",
6
6
  "packages": [
7
7
  {
8
- "name": "ecommerce",
9
- "location": "https://github.com/credibledata/malloy-samples/tree/main/ecommerce"
8
+ "name": "storefront",
9
+ "location": "https://github.com/malloydata/publisher/tree/main/examples/storefront"
10
10
  },
11
11
  {
12
- "name": "imdb",
13
- "location": "https://github.com/credibledata/malloy-samples/tree/main/imdb"
12
+ "name": "governed-analytics",
13
+ "location": "https://github.com/malloydata/publisher/tree/main/examples/governed-analytics"
14
14
  },
15
15
  {
16
- "name": "faa",
17
- "location": "https://github.com/credibledata/malloy-samples/tree/main/faa"
16
+ "name": "html-data-app",
17
+ "location": "https://github.com/malloydata/publisher/tree/main/examples/html-data-app"
18
18
  }
19
19
  ],
20
20
  "connections": []
@@ -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
+ });
@@ -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