@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
package/src/mcp/handler_utils.ts
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type { ResourceMetadata } from "@modelcontextprotocol/sdk/server/mcp";
|
|
3
|
-
import type { ReadResourceResult } from "@modelcontextprotocol/sdk/types.js";
|
|
1
|
+
import { MalloyError } from "@malloydata/malloy";
|
|
4
2
|
import { EnvironmentStore } from "../service/environment_store";
|
|
5
3
|
import {
|
|
6
4
|
AccessDeniedError,
|
|
5
|
+
BadRequestError,
|
|
7
6
|
PackageNotFoundError,
|
|
8
7
|
ModelNotFoundError,
|
|
9
8
|
ModelCompilationError,
|
|
10
9
|
EnvironmentNotFoundError,
|
|
10
|
+
NotQueryableError,
|
|
11
|
+
PayloadTooLargeError,
|
|
12
|
+
QueryTimeoutError,
|
|
11
13
|
ServiceUnavailableError,
|
|
12
14
|
} from "../errors";
|
|
13
15
|
import {
|
|
@@ -19,103 +21,96 @@ import {
|
|
|
19
21
|
import type { Model } from "../service/model";
|
|
20
22
|
import { logger } from "../logger";
|
|
21
23
|
|
|
22
|
-
// Custom error to wrap specific GetResource application errors
|
|
23
|
-
export class McpGetResourceError extends Error {
|
|
24
|
-
details: ErrorDetails;
|
|
25
|
-
|
|
26
|
-
constructor(details: ErrorDetails) {
|
|
27
|
-
super(details.message); // Pass message to the base Error constructor
|
|
28
|
-
this.name = "McpGetResourceError"; // Custom error name
|
|
29
|
-
this.details = details; // Store the structured details
|
|
30
|
-
|
|
31
|
-
// Maintain stack trace (important for V8)
|
|
32
|
-
if (Error.captureStackTrace) {
|
|
33
|
-
Error.captureStackTrace(this, McpGetResourceError);
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
// Helper type for the data fetching logic within a resource handler
|
|
39
|
-
type GetDataLogic<TParams, TDefinition> = (
|
|
40
|
-
params: TParams,
|
|
41
|
-
uri: URL,
|
|
42
|
-
) => Promise<TDefinition>;
|
|
43
|
-
|
|
44
24
|
/**
|
|
45
|
-
*
|
|
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.
|
|
46
28
|
*/
|
|
47
|
-
export
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
uri: URL,
|
|
52
|
-
params: TParams,
|
|
53
|
-
resourceType: string, // e.g., 'environment', 'package' for logging/errors
|
|
54
|
-
getData: GetDataLogic<TParams, TDefinition>,
|
|
55
|
-
resourceMetadata: ResourceMetadata | undefined,
|
|
56
|
-
): Promise<ReadResourceResult> {
|
|
57
|
-
try {
|
|
58
|
-
const definition = await getData(params, uri);
|
|
59
|
-
|
|
60
|
-
// Combine definition and metadata
|
|
61
|
-
const responsePayload = JSON.stringify(
|
|
62
|
-
{ definition: definition, metadata: resourceMetadata },
|
|
63
|
-
null,
|
|
64
|
-
2,
|
|
65
|
-
);
|
|
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
|
+
];
|
|
66
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.
|
|
67
73
|
return {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
let errorDetails: ErrorDetails;
|
|
83
|
-
|
|
84
|
-
// Determine the correct error details based on the error type
|
|
85
|
-
if (error instanceof McpGetResourceError) {
|
|
86
|
-
// The getData function already caught, formatted, and wrapped the error
|
|
87
|
-
errorDetails = error.details;
|
|
88
|
-
} else {
|
|
89
|
-
// Catch-all for truly unexpected errors not handled by the specific getData logic
|
|
90
|
-
logger.error(
|
|
91
|
-
"[MCP Server Error] Unexpected error type caught in handleResourceGet:",
|
|
92
|
-
{ error },
|
|
93
|
-
);
|
|
94
|
-
errorDetails = getInternalError(
|
|
95
|
-
`GetResource (${resourceType})`,
|
|
96
|
-
error,
|
|
97
|
-
);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// Format the error response consistently
|
|
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).
|
|
101
88
|
return {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
uri: uri.href,
|
|
107
|
-
text: JSON.stringify(
|
|
108
|
-
{
|
|
109
|
-
error: errorDetails.message,
|
|
110
|
-
suggestions: errorDetails.suggestions,
|
|
111
|
-
},
|
|
112
|
-
null,
|
|
113
|
-
2,
|
|
114
|
-
),
|
|
115
|
-
},
|
|
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.",
|
|
116
93
|
],
|
|
117
|
-
};
|
|
94
|
+
} satisfies ErrorDetails;
|
|
118
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);
|
|
119
114
|
}
|
|
120
115
|
|
|
121
116
|
/**
|
|
@@ -184,10 +179,7 @@ export async function getModelForQuery(
|
|
|
184
179
|
// server's own message so the MCP caller knows to retry.
|
|
185
180
|
errorDetails = {
|
|
186
181
|
message: error.message,
|
|
187
|
-
suggestions: [
|
|
188
|
-
"Retry after the publisher's memory usage drops below the configured low-water mark.",
|
|
189
|
-
"If this happens repeatedly, raise PUBLISHER_MAX_MEMORY_BYTES or scale up the pod.",
|
|
190
|
-
],
|
|
182
|
+
suggestions: [...BACK_PRESSURE_SUGGESTIONS],
|
|
191
183
|
} satisfies ErrorDetails;
|
|
192
184
|
} else {
|
|
193
185
|
// Unexpected error during setup
|
|
@@ -202,28 +194,20 @@ export async function getModelForQuery(
|
|
|
202
194
|
}
|
|
203
195
|
|
|
204
196
|
/**
|
|
205
|
-
* Constructs a
|
|
206
|
-
*
|
|
197
|
+
* Constructs a malloy:// URI string identifying the model a tool response came
|
|
198
|
+
* from. Attached as `uri` metadata on tool results (get-context, execute-query,
|
|
199
|
+
* docs-search); it is not a fetchable MCP resource.
|
|
207
200
|
*
|
|
208
|
-
* @param components
|
|
209
|
-
* @param fragment Optional fragment identifier (e.g.,
|
|
210
|
-
* @returns A
|
|
201
|
+
* @param components The URI parts (environment, package, and optionally the model).
|
|
202
|
+
* @param fragment Optional fragment identifier (e.g., "result", "get-context").
|
|
203
|
+
* @returns A malloy:// URI string.
|
|
211
204
|
*/
|
|
212
205
|
export function buildMalloyUri(
|
|
213
206
|
components: {
|
|
214
207
|
environment?: string;
|
|
215
208
|
package?: string;
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
| "models"
|
|
219
|
-
| "packages"
|
|
220
|
-
| "notebooks"
|
|
221
|
-
| "sources"
|
|
222
|
-
| "queries"
|
|
223
|
-
| "views"; // Type of resource list or specific resource
|
|
224
|
-
resourceName?: string; // Specific name for model, query, etc.
|
|
225
|
-
subResourceType?: "views"; // For views within sources
|
|
226
|
-
subResourceName?: string; // Name of the view
|
|
209
|
+
resourceType?: "models";
|
|
210
|
+
resourceName?: string;
|
|
227
211
|
},
|
|
228
212
|
fragment?: string,
|
|
229
213
|
): string {
|
|
@@ -244,20 +228,11 @@ export function buildMalloyUri(
|
|
|
244
228
|
path += "/" + components.resourceType;
|
|
245
229
|
if (components.resourceName) {
|
|
246
230
|
path += "/" + encodeURIComponent(components.resourceName);
|
|
247
|
-
|
|
248
|
-
if (components.subResourceType && components.subResourceName) {
|
|
249
|
-
path +=
|
|
250
|
-
"/" +
|
|
251
|
-
components.subResourceType +
|
|
252
|
-
"/" +
|
|
253
|
-
encodeURIComponent(components.subResourceName);
|
|
254
|
-
}
|
|
255
231
|
}
|
|
256
232
|
}
|
|
257
233
|
|
|
258
|
-
// The URL constructor
|
|
259
|
-
//
|
|
260
|
-
// We manually construct the string instead.
|
|
234
|
+
// The URL constructor normalizes malloy://path to malloy:///path, so we
|
|
235
|
+
// build the string manually to keep the malloy://environment/... shape.
|
|
261
236
|
let uriString = "malloy:/" + path; // Start with one slash after scheme
|
|
262
237
|
|
|
263
238
|
if (fragment) {
|
package/src/mcp/mcp_constants.ts
CHANGED
|
@@ -3,20 +3,4 @@ export const MCP_ERROR_MESSAGES = {
|
|
|
3
3
|
MISSING_REQUIRED_PARAMS:
|
|
4
4
|
"Either 'query' or both 'sourceName' and 'queryName' must be provided",
|
|
5
5
|
MUTUALLY_EXCLUSIVE_PARAMS: "Cannot provide both 'query' and 'queryName'",
|
|
6
|
-
INVALID_ARGUMENTS: (toolName: string, errors: string[]) =>
|
|
7
|
-
`Invalid arguments for tool ${toolName}: ${errors.join(", ")}`,
|
|
8
|
-
|
|
9
|
-
// Application-level errors (runtime)
|
|
10
|
-
PROJECT_NOT_FOUND: (projectName: string) =>
|
|
11
|
-
`Environment '${projectName}' is not available or does not exist.`,
|
|
12
|
-
PACKAGE_NOT_FOUND: (packageName: string) =>
|
|
13
|
-
`Package manifest for ${packageName} does not exist.`,
|
|
14
|
-
MODEL_NOT_FOUND: (packageName: string, modelPath: string) =>
|
|
15
|
-
`Model '${modelPath}' not found in package '${packageName}'`,
|
|
16
|
-
QUERY_NOT_FOUND: (queryName: string) => `'${queryName}' is not defined`,
|
|
17
|
-
COMPILATION_ERROR: (error: string) => `Error(s) compiling model:\n${error}`,
|
|
18
|
-
|
|
19
|
-
// Error message prefixes
|
|
20
|
-
ERROR_EXECUTING_QUERY: (packagePath: string) =>
|
|
21
|
-
`Error executing query on '${packagePath}':`,
|
|
22
6
|
} as const;
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { beforeAll, describe, expect, it } from "bun:test";
|
|
2
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
3
|
+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
4
|
+
import { initializeMcpServer } from "./server";
|
|
5
|
+
import { RELOAD_FAILURE_IS_SAFE } from "./tools/reload_package_tool";
|
|
6
|
+
import type { EnvironmentStore } from "../service/environment_store";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* End-to-end coverage of the unified MCP server over the real MCP protocol,
|
|
10
|
+
* using the SDK's in-memory transport (no HTTP, no network, no DuckDB).
|
|
11
|
+
* Exercises tool registration (including the agent retrieval tools that now
|
|
12
|
+
* live on the same server), the dual-channel skill prompts, a real tool call
|
|
13
|
+
* (searchDocs over the bundled index), and the getContext error path.
|
|
14
|
+
*/
|
|
15
|
+
describe("MCP server over the MCP protocol (in-memory)", () => {
|
|
16
|
+
let client: Client;
|
|
17
|
+
|
|
18
|
+
beforeAll(async () => {
|
|
19
|
+
// searchDocs does not touch the store; getContext's error path only needs
|
|
20
|
+
// getEnvironment to reject, so a throwing stub is sufficient here.
|
|
21
|
+
const stubStore = {
|
|
22
|
+
getEnvironment: async (name: string): Promise<never> => {
|
|
23
|
+
throw new Error(`Environment not found: ${name}`);
|
|
24
|
+
},
|
|
25
|
+
} as unknown as EnvironmentStore;
|
|
26
|
+
|
|
27
|
+
const server = initializeMcpServer(stubStore);
|
|
28
|
+
const [clientTransport, serverTransport] =
|
|
29
|
+
InMemoryTransport.createLinkedPair();
|
|
30
|
+
await server.connect(serverTransport);
|
|
31
|
+
client = new Client({ name: "mcp-protocol-test", version: "0.0.0" });
|
|
32
|
+
await client.connect(clientTransport);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("exposes the agent retrieval tools alongside the core tools", async () => {
|
|
36
|
+
const { tools } = await client.listTools();
|
|
37
|
+
const names = new Set(tools.map((t) => t.name));
|
|
38
|
+
expect(names.has("malloy_getContext")).toBe(true);
|
|
39
|
+
expect(names.has("malloy_searchDocs")).toBe(true);
|
|
40
|
+
expect(names.has("malloy_executeQuery")).toBe(true);
|
|
41
|
+
expect(names.has("malloy_compile")).toBe(true);
|
|
42
|
+
expect(names.has("malloy_reloadPackage")).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("exposes the skill set as dual-channel prompts", async () => {
|
|
46
|
+
const { prompts } = await client.listPrompts();
|
|
47
|
+
expect(prompts.length).toBeGreaterThanOrEqual(24);
|
|
48
|
+
expect(prompts.some((p) => p.name === "malloy-analysis")).toBe(true);
|
|
49
|
+
});
|
|
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
|
+
|
|
62
|
+
it("malloy_searchDocs returns relevant docs over the protocol", async () => {
|
|
63
|
+
const res = await client.callTool({
|
|
64
|
+
name: "malloy_searchDocs",
|
|
65
|
+
arguments: { query: "window functions" },
|
|
66
|
+
});
|
|
67
|
+
const content = res.content as Array<{ resource?: { text?: string } }>;
|
|
68
|
+
const results = JSON.parse(
|
|
69
|
+
content?.[0]?.resource?.text ?? "[]",
|
|
70
|
+
) as Array<{
|
|
71
|
+
url: string;
|
|
72
|
+
}>;
|
|
73
|
+
expect(results.length).toBeGreaterThan(0);
|
|
74
|
+
expect(results[0].url.length).toBeGreaterThan(0);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("malloy_getContext fails gracefully (isError) for an unknown environment", async () => {
|
|
78
|
+
const res = await client.callTool({
|
|
79
|
+
name: "malloy_getContext",
|
|
80
|
+
arguments: {
|
|
81
|
+
environmentName: "nope",
|
|
82
|
+
packageName: "nope",
|
|
83
|
+
query: "x",
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
expect(res.isError).toBe(true);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
/**
|
|
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
|
+
|
|
133
|
+
it("a skill prompt returns its body", async () => {
|
|
134
|
+
const res = await client.getPrompt({ name: "malloy-analysis" });
|
|
135
|
+
const content = res.messages[0].content as { text?: string };
|
|
136
|
+
expect((content.text ?? "").length).toBeGreaterThan(200);
|
|
137
|
+
});
|
|
138
|
+
});
|
package/src/mcp/server.ts
CHANGED
|
@@ -2,16 +2,15 @@ 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 {
|
|
6
|
-
import {
|
|
7
|
-
import { registerModelResource } from "./resources/model_resource";
|
|
8
|
-
import { registerNotebookResource } from "./resources/notebook_resource";
|
|
9
|
-
import { registerPackageResource } from "./resources/package_resource";
|
|
10
|
-
import { registerQueryResource } from "./resources/query_resource";
|
|
11
|
-
import { registerSourceResource } from "./resources/source_resource";
|
|
12
|
-
import { registerViewResource } from "./resources/view_resource";
|
|
13
|
-
import { registerTools } from "./tools/discovery_tools";
|
|
5
|
+
import { registerCompileTool } from "./tools/compile_tool";
|
|
6
|
+
import { registerDocsSearchTool } from "./tools/docs_search_tool";
|
|
14
7
|
import { registerExecuteQueryTool } from "./tools/execute_query_tool";
|
|
8
|
+
import { registerGetContextTool } from "./tools/get_context_tool";
|
|
9
|
+
import {
|
|
10
|
+
registerReloadPackageTool,
|
|
11
|
+
RELOAD_FAILURE_IS_SAFE,
|
|
12
|
+
} from "./tools/reload_package_tool";
|
|
13
|
+
import skillsBundle from "./skills/skills_bundle.json";
|
|
15
14
|
|
|
16
15
|
export const testServerInfo = {
|
|
17
16
|
name: "malloy-publisher-mcp-server",
|
|
@@ -20,43 +19,64 @@ export const testServerInfo = {
|
|
|
20
19
|
description: "Provides access to Malloy models and query execution via MCP.",
|
|
21
20
|
};
|
|
22
21
|
|
|
22
|
+
// Build-time bundle of the agent skills (see skills/build_skills_bundle.ts),
|
|
23
|
+
// exposed as MCP prompts for the dual-channel delivery below.
|
|
24
|
+
const AGENT_SKILLS = (
|
|
25
|
+
skillsBundle as {
|
|
26
|
+
skills: { name: string; description: string; body: string }[];
|
|
27
|
+
}
|
|
28
|
+
).skills;
|
|
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
|
+
|
|
23
49
|
export function initializeMcpServer(
|
|
24
50
|
environmentStore: EnvironmentStore,
|
|
25
51
|
): McpServer {
|
|
26
52
|
logger.info("[MCP Init] Starting initializeMcpServer...");
|
|
27
53
|
const startTime = performance.now();
|
|
28
54
|
|
|
29
|
-
const mcpServer = new McpServer(testServerInfo
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
registerEnvironmentResource(mcpServer, environmentStore);
|
|
33
|
-
logger.info("[MCP Init] Registering package resource...");
|
|
34
|
-
registerPackageResource(mcpServer, environmentStore);
|
|
35
|
-
|
|
36
|
-
// Register more specific templates first
|
|
37
|
-
logger.info("[MCP Init] Registering notebook resource...");
|
|
38
|
-
registerNotebookResource(mcpServer, environmentStore);
|
|
39
|
-
logger.info("[MCP Init] Registering source resource...");
|
|
40
|
-
registerSourceResource(mcpServer, environmentStore);
|
|
41
|
-
logger.info("[MCP Init] Registering query resource...");
|
|
42
|
-
registerQueryResource(mcpServer, environmentStore);
|
|
43
|
-
logger.info("[MCP Init] Registering view resource...");
|
|
44
|
-
registerViewResource(mcpServer, environmentStore);
|
|
45
|
-
|
|
46
|
-
// Register the general model template last among resource types
|
|
47
|
-
logger.info("[MCP Init] Registering model resource...");
|
|
48
|
-
registerModelResource(mcpServer, environmentStore);
|
|
55
|
+
const mcpServer = new McpServer(testServerInfo, {
|
|
56
|
+
instructions: MCP_INSTRUCTIONS,
|
|
57
|
+
});
|
|
49
58
|
|
|
50
|
-
logger.info("[MCP Init] Registering executeQuery tool...");
|
|
51
59
|
registerExecuteQueryTool(mcpServer, environmentStore);
|
|
60
|
+
registerGetContextTool(mcpServer, environmentStore);
|
|
61
|
+
registerDocsSearchTool(mcpServer, environmentStore);
|
|
62
|
+
registerCompileTool(mcpServer, environmentStore);
|
|
63
|
+
registerReloadPackageTool(mcpServer, environmentStore);
|
|
52
64
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
65
|
+
// Dual-channel: also expose each skill as an MCP prompt, so hosts that ingest
|
|
66
|
+
// MCP but do not load skill files (e.g. Codex, ChatGPT, Cursor) can pull the
|
|
67
|
+
// same guidance. Skill-aware hosts (Claude Code/Desktop) use the native skill
|
|
68
|
+
// files. Note: MCP prompts are on-demand, so always-on "prevention" skills
|
|
69
|
+
// become on-demand here; see docs/agent-skills/design-principles.md.
|
|
70
|
+
for (const skill of AGENT_SKILLS) {
|
|
71
|
+
mcpServer.prompt(skill.name, skill.description, () => ({
|
|
72
|
+
messages: [
|
|
73
|
+
{
|
|
74
|
+
role: "user" as const,
|
|
75
|
+
content: { type: "text" as const, text: skill.body },
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
}));
|
|
79
|
+
}
|
|
60
80
|
|
|
61
81
|
const endTime = performance.now();
|
|
62
82
|
logger.info(`[MCP Init] Finished initializeMcpServer`, {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Build a bundled JSON of the agent skills so the
|
|
2
|
+
* Build a bundled JSON of the agent skills so the MCP server can expose them as
|
|
3
3
|
* MCP prompts (dual-channel): skill-aware hosts read the skill files directly, while
|
|
4
4
|
* hosts that only ingest MCP get the same guidance through these prompts.
|
|
5
5
|
*
|