@malloy-publisher/server 0.0.194 → 0.0.196-dev
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/server.mjs +68 -22
- package/package.json +2 -2
- package/src/service/connection_config.spec.ts +46 -1
- package/src/service/connection_config.ts +29 -0
- package/src/service/model.ts +2 -2
- package/src/service/project.ts +4 -0
- package/src/service/project_store.spec.ts +77 -0
- package/src/service/project_store.ts +12 -4
- package/src/storage/StorageManager.ts +45 -10
- package/src/storage/ducklake/DuckLakeManifestStore.ts +20 -11
- package/tests/harness/mcp_test_setup.ts +11 -23
- package/tests/integration/mcp/mcp_execute_query_tool.integration.spec.ts +1 -1
- package/tests/integration/mcp/mcp_resource.integration.spec.ts +13 -12
- package/tests/integration/mcp/mcp_transport.integration.spec.ts +1 -1
package/dist/server.mjs
CHANGED
|
@@ -220381,6 +220381,7 @@ import fs from "fs/promises";
|
|
|
220381
220381
|
import path2 from "path";
|
|
220382
220382
|
|
|
220383
220383
|
// src/service/connection_config.ts
|
|
220384
|
+
import { createPrivateKey } from "crypto";
|
|
220384
220385
|
import path from "path";
|
|
220385
220386
|
var PUBLISHER_DUCKDB_API_FIELDS = new Set(["attachedDatabases"]);
|
|
220386
220387
|
function normalizeSnowflakePrivateKey(privateKey) {
|
|
@@ -220399,6 +220400,12 @@ function normalizeSnowflakePrivateKey(privateKey) {
|
|
|
220399
220400
|
endRegex: /-----END\s+PRIVATE\s+KEY-----/i,
|
|
220400
220401
|
beginMarker: "-----BEGIN PRIVATE KEY-----",
|
|
220401
220402
|
endMarker: "-----END PRIVATE KEY-----"
|
|
220403
|
+
},
|
|
220404
|
+
{
|
|
220405
|
+
beginRegex: /-----BEGIN\s+RSA\s+PRIVATE\s+KEY-----/i,
|
|
220406
|
+
endRegex: /-----END\s+RSA\s+PRIVATE\s+KEY-----/i,
|
|
220407
|
+
beginMarker: "-----BEGIN RSA PRIVATE KEY-----",
|
|
220408
|
+
endMarker: "-----END RSA PRIVATE KEY-----"
|
|
220402
220409
|
}
|
|
220403
220410
|
];
|
|
220404
220411
|
for (const pattern of keyPatterns) {
|
|
@@ -220425,6 +220432,21 @@ ${pattern.endMarker}
|
|
|
220425
220432
|
privateKeyContent += `
|
|
220426
220433
|
`;
|
|
220427
220434
|
}
|
|
220435
|
+
if (/-----BEGIN\s+RSA\s+PRIVATE\s+KEY-----/i.test(privateKeyContent)) {
|
|
220436
|
+
try {
|
|
220437
|
+
privateKeyContent = createPrivateKey({
|
|
220438
|
+
key: privateKeyContent,
|
|
220439
|
+
format: "pem"
|
|
220440
|
+
}).export({ type: "pkcs8", format: "pem" }).toString();
|
|
220441
|
+
} catch (err) {
|
|
220442
|
+
throw new Error(`Failed to convert Snowflake RSA private key (PKCS#1) to PKCS#8: ${err instanceof Error ? err.message : String(err)}`);
|
|
220443
|
+
}
|
|
220444
|
+
if (!privateKeyContent.endsWith(`
|
|
220445
|
+
`)) {
|
|
220446
|
+
privateKeyContent += `
|
|
220447
|
+
`;
|
|
220448
|
+
}
|
|
220449
|
+
}
|
|
220428
220450
|
return privateKeyContent;
|
|
220429
220451
|
}
|
|
220430
220452
|
function validateDuckdbApiSurface(connection) {
|
|
@@ -224989,7 +225011,7 @@ class Mutex {
|
|
|
224989
225011
|
}
|
|
224990
225012
|
|
|
224991
225013
|
// src/service/project_store.ts
|
|
224992
|
-
import
|
|
225014
|
+
import crypto4 from "crypto";
|
|
224993
225015
|
import * as fs7 from "fs";
|
|
224994
225016
|
import * as path9 from "path";
|
|
224995
225017
|
|
|
@@ -229231,6 +229253,9 @@ function registerHealthEndpoints(app) {
|
|
|
229231
229253
|
});
|
|
229232
229254
|
}
|
|
229233
229255
|
|
|
229256
|
+
// src/storage/StorageManager.ts
|
|
229257
|
+
import * as crypto3 from "crypto";
|
|
229258
|
+
|
|
229234
229259
|
// src/storage/duckdb/DuckDBConnection.ts
|
|
229235
229260
|
import duckdb from "duckdb";
|
|
229236
229261
|
import * as path5 from "path";
|
|
@@ -230146,15 +230171,17 @@ async function dropAllTables(db) {
|
|
|
230146
230171
|
class DuckLakeManifestStore {
|
|
230147
230172
|
db;
|
|
230148
230173
|
table;
|
|
230149
|
-
|
|
230174
|
+
projectName;
|
|
230175
|
+
constructor(db, catalogName, projectName) {
|
|
230150
230176
|
this.db = db;
|
|
230151
230177
|
this.table = `${catalogName}.build_manifests`;
|
|
230178
|
+
this.projectName = projectName;
|
|
230152
230179
|
}
|
|
230153
230180
|
async bootstrapSchema() {
|
|
230154
230181
|
await this.db.run(`
|
|
230155
230182
|
CREATE TABLE IF NOT EXISTS ${this.table} (
|
|
230156
230183
|
id VARCHAR,
|
|
230157
|
-
|
|
230184
|
+
project_name VARCHAR NOT NULL,
|
|
230158
230185
|
package_name VARCHAR NOT NULL,
|
|
230159
230186
|
build_id VARCHAR NOT NULL,
|
|
230160
230187
|
table_name VARCHAR NOT NULL,
|
|
@@ -230166,8 +230193,8 @@ class DuckLakeManifestStore {
|
|
|
230166
230193
|
`);
|
|
230167
230194
|
logger.info(`DuckLake manifest table bootstrapped: ${this.table}`);
|
|
230168
230195
|
}
|
|
230169
|
-
async getManifest(
|
|
230170
|
-
const rows = await this.db.all(`SELECT * FROM ${this.table} WHERE
|
|
230196
|
+
async getManifest(_projectId, packageName) {
|
|
230197
|
+
const rows = await this.db.all(`SELECT * FROM ${this.table} WHERE project_name = ? AND package_name = ? ORDER BY created_at DESC`, [this.projectName, packageName]);
|
|
230171
230198
|
const manifest = { entries: {}, strict: false };
|
|
230172
230199
|
for (const row of rows) {
|
|
230173
230200
|
const buildId = row.build_id;
|
|
@@ -230179,13 +230206,13 @@ class DuckLakeManifestStore {
|
|
|
230179
230206
|
}
|
|
230180
230207
|
return manifest;
|
|
230181
230208
|
}
|
|
230182
|
-
async writeEntry(
|
|
230209
|
+
async writeEntry(_projectId, packageName, buildId, tableName, sourceName, connectionName) {
|
|
230183
230210
|
const now = new Date().toISOString();
|
|
230184
230211
|
const id = `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
|
|
230185
|
-
await this.db.run(`INSERT INTO ${this.table} (id,
|
|
230212
|
+
await this.db.run(`INSERT INTO ${this.table} (id, project_name, package_name, build_id, table_name, source_name, connection_name, created_at, updated_at)
|
|
230186
230213
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
230187
230214
|
id,
|
|
230188
|
-
|
|
230215
|
+
this.projectName,
|
|
230189
230216
|
packageName,
|
|
230190
230217
|
buildId,
|
|
230191
230218
|
tableName,
|
|
@@ -230198,14 +230225,14 @@ class DuckLakeManifestStore {
|
|
|
230198
230225
|
async deleteEntry(id) {
|
|
230199
230226
|
await this.db.run(`DELETE FROM ${this.table} WHERE id = ?`, [id]);
|
|
230200
230227
|
}
|
|
230201
|
-
async listEntries(
|
|
230202
|
-
const rows = await this.db.all(`SELECT * FROM ${this.table} WHERE
|
|
230228
|
+
async listEntries(_projectId, packageName) {
|
|
230229
|
+
const rows = await this.db.all(`SELECT * FROM ${this.table} WHERE project_name = ? AND package_name = ? ORDER BY created_at DESC`, [this.projectName, packageName]);
|
|
230203
230230
|
return rows.map(this.mapToEntry);
|
|
230204
230231
|
}
|
|
230205
230232
|
mapToEntry(row) {
|
|
230206
230233
|
return {
|
|
230207
230234
|
id: row.id,
|
|
230208
|
-
projectId: row.
|
|
230235
|
+
projectId: row.project_name,
|
|
230209
230236
|
packageName: row.package_name,
|
|
230210
230237
|
buildId: row.build_id,
|
|
230211
230238
|
tableName: row.table_name,
|
|
@@ -230221,6 +230248,13 @@ class DuckLakeManifestStore {
|
|
|
230221
230248
|
function escapeSQL2(value) {
|
|
230222
230249
|
return value.replace(/'/g, "''");
|
|
230223
230250
|
}
|
|
230251
|
+
function configKey(c) {
|
|
230252
|
+
return `${c.catalogUrl}|${c.dataPath}`;
|
|
230253
|
+
}
|
|
230254
|
+
function catalogNameForConfig(c) {
|
|
230255
|
+
const hash = crypto3.createHash("sha256").update(configKey(c)).digest("hex").slice(0, 8);
|
|
230256
|
+
return `manifest_lake_${hash}`;
|
|
230257
|
+
}
|
|
230224
230258
|
|
|
230225
230259
|
class StorageManager {
|
|
230226
230260
|
connection = null;
|
|
@@ -230228,7 +230262,7 @@ class StorageManager {
|
|
|
230228
230262
|
repository = null;
|
|
230229
230263
|
defaultManifestStore = null;
|
|
230230
230264
|
projectManifestStores = new Map;
|
|
230231
|
-
attachedCatalogs = new
|
|
230265
|
+
attachedCatalogs = new Map;
|
|
230232
230266
|
config;
|
|
230233
230267
|
constructor(config) {
|
|
230234
230268
|
this.config = config;
|
|
@@ -230261,19 +230295,23 @@ class StorageManager {
|
|
|
230261
230295
|
this.repository = new DuckDBRepository(connection);
|
|
230262
230296
|
this.defaultManifestStore = new DuckDBManifestStore(this.repository);
|
|
230263
230297
|
}
|
|
230264
|
-
async initializeDuckLakeForProject(projectId, config) {
|
|
230298
|
+
async initializeDuckLakeForProject(projectId, projectName, config) {
|
|
230265
230299
|
if (!this.duckDbConnection) {
|
|
230266
230300
|
throw new Error("Storage not initialized. Call initialize() first.");
|
|
230267
230301
|
}
|
|
230268
|
-
const
|
|
230269
|
-
|
|
230302
|
+
const key = configKey(config);
|
|
230303
|
+
let catalogName = this.attachedCatalogs.get(key);
|
|
230304
|
+
if (!catalogName) {
|
|
230305
|
+
catalogName = catalogNameForConfig(config);
|
|
230270
230306
|
await this.attachDuckLakeCatalog(config, catalogName);
|
|
230307
|
+
this.attachedCatalogs.set(key, catalogName);
|
|
230271
230308
|
}
|
|
230272
|
-
const store = new DuckLakeManifestStore(this.duckDbConnection, catalogName);
|
|
230309
|
+
const store = new DuckLakeManifestStore(this.duckDbConnection, catalogName, projectName);
|
|
230273
230310
|
await store.bootstrapSchema();
|
|
230274
230311
|
this.projectManifestStores.set(projectId, store);
|
|
230275
230312
|
logger.info("DuckLake manifest store initialized for project", {
|
|
230276
230313
|
projectId,
|
|
230314
|
+
projectName,
|
|
230277
230315
|
catalogName
|
|
230278
230316
|
});
|
|
230279
230317
|
}
|
|
@@ -230288,14 +230326,16 @@ class StorageManager {
|
|
|
230288
230326
|
const escapedDataPath = escapeSQL2(config.dataPath);
|
|
230289
230327
|
const isCloudStorage = config.dataPath.startsWith("gs://") || config.dataPath.startsWith("s3://");
|
|
230290
230328
|
let attachCmd = `ATTACH 'ducklake:${escapedCatalogUrl}' AS ${catalogName}`;
|
|
230291
|
-
const attachOpts = [
|
|
230329
|
+
const attachOpts = [
|
|
230330
|
+
`DATA_PATH '${escapedDataPath}'`,
|
|
230331
|
+
"DATA_INLINING_ROW_LIMIT 100000"
|
|
230332
|
+
];
|
|
230292
230333
|
if (isCloudStorage) {
|
|
230293
230334
|
attachOpts.push("OVERRIDE_DATA_PATH true");
|
|
230294
230335
|
}
|
|
230295
230336
|
attachCmd += ` (${attachOpts.join(", ")});`;
|
|
230296
230337
|
logger.info(`Attaching DuckLake manifest catalog: ${attachCmd}`);
|
|
230297
230338
|
await connection.run(attachCmd);
|
|
230298
|
-
this.attachedCatalogs.add(catalogName);
|
|
230299
230339
|
}
|
|
230300
230340
|
getRepository() {
|
|
230301
230341
|
if (!this.repository) {
|
|
@@ -230379,8 +230419,8 @@ import {
|
|
|
230379
230419
|
MalloySQLParser,
|
|
230380
230420
|
MalloySQLStatementType
|
|
230381
230421
|
} from "@malloydata/malloy-sql";
|
|
230382
|
-
import { createRequire as createRequire2 } from "module";
|
|
230383
230422
|
import * as fs4 from "fs/promises";
|
|
230423
|
+
import { createRequire as createRequire2 } from "module";
|
|
230384
230424
|
import * as path6 from "path";
|
|
230385
230425
|
|
|
230386
230426
|
// src/data_styles.ts
|
|
@@ -231010,7 +231050,7 @@ run: ${sourceName ? sourceName + "->" : ""}${queryName}`;
|
|
|
231010
231050
|
}
|
|
231011
231051
|
const modelURL = new URL(`file://${fullModelPath}`);
|
|
231012
231052
|
const baseUrl = new URL(".", modelURL);
|
|
231013
|
-
const importBaseURL =
|
|
231053
|
+
const importBaseURL = baseUrl;
|
|
231014
231054
|
const urlReader = new HackyDataStylesAccumulator(URL_READER);
|
|
231015
231055
|
const runtime = new Runtime({
|
|
231016
231056
|
urlReader,
|
|
@@ -231556,6 +231596,9 @@ class Project {
|
|
|
231556
231596
|
this.metadata.readme = payload.readme;
|
|
231557
231597
|
await this.writeProjectReadme(payload.readme);
|
|
231558
231598
|
}
|
|
231599
|
+
if (payload.materializationStorage !== undefined) {
|
|
231600
|
+
this.metadata.materializationStorage = payload.materializationStorage;
|
|
231601
|
+
}
|
|
231559
231602
|
if (payload.connections) {
|
|
231560
231603
|
const payloadConnections = payload.connections;
|
|
231561
231604
|
await this.runConnectionUpdateExclusive(async () => {
|
|
@@ -232131,7 +232174,7 @@ class ProjectStore {
|
|
|
232131
232174
|
}
|
|
232132
232175
|
const materializationStorage = project.metadata?.materializationStorage;
|
|
232133
232176
|
if (materializationStorage?.catalogUrl && materializationStorage?.dataPath) {
|
|
232134
|
-
await this.storageManager.initializeDuckLakeForProject(dbProject.id, {
|
|
232177
|
+
await this.storageManager.initializeDuckLakeForProject(dbProject.id, dbProject.name, {
|
|
232135
232178
|
catalogUrl: materializationStorage.catalogUrl,
|
|
232136
232179
|
dataPath: materializationStorage.dataPath
|
|
232137
232180
|
});
|
|
@@ -232407,6 +232450,9 @@ class ProjectStore {
|
|
|
232407
232450
|
if (!newProject.metadata)
|
|
232408
232451
|
newProject.metadata = {};
|
|
232409
232452
|
newProject.metadata.location = absoluteProjectPath;
|
|
232453
|
+
if (project.materializationStorage !== undefined) {
|
|
232454
|
+
newProject.metadata.materializationStorage = project.materializationStorage;
|
|
232455
|
+
}
|
|
232410
232456
|
this.projects.set(projectName, newProject);
|
|
232411
232457
|
project?.packages?.forEach((_package) => {
|
|
232412
232458
|
if (_package.name) {
|
|
@@ -232561,7 +232607,7 @@ class ProjectStore {
|
|
|
232561
232607
|
});
|
|
232562
232608
|
}
|
|
232563
232609
|
for (const [groupedLocation, packagesForLocation] of locationGroups) {
|
|
232564
|
-
const locationHash =
|
|
232610
|
+
const locationHash = crypto4.createHash("sha256").update(groupedLocation).digest("hex").substring(0, 16);
|
|
232565
232611
|
const tempDownloadPath = `${absoluteTargetPath}/.temp_${locationHash}`;
|
|
232566
232612
|
await fs7.promises.mkdir(tempDownloadPath, { recursive: true });
|
|
232567
232613
|
logger.info(`Created temporary directory: ${tempDownloadPath}`);
|
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.
|
|
4
|
+
"version": "0.0.196-dev",
|
|
5
5
|
"main": "dist/server.mjs",
|
|
6
6
|
"bin": {
|
|
7
7
|
"malloy-publisher": "dist/server.mjs"
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"scripts": {
|
|
17
17
|
"test": "bun run test:unit && bun run test:integration",
|
|
18
18
|
"test:unit": "bun test --timeout 100000 src",
|
|
19
|
-
"test:integration": "bun test --timeout
|
|
19
|
+
"test:integration": "bun test --timeout 200000 tests --max-workers=1",
|
|
20
20
|
"build": "bun generate-api-types && bun build:app && NODE_ENV=production bun run build.ts",
|
|
21
21
|
"build:server-only": "bun generate-api-types && NODE_ENV=production bun run build.ts",
|
|
22
22
|
"start": "NODE_ENV=production bun run ./dist/server.mjs",
|
|
@@ -1,5 +1,9 @@
|
|
|
1
|
+
import { generateKeyPairSync } from "crypto";
|
|
1
2
|
import { describe, expect, it } from "bun:test";
|
|
2
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
assembleProjectConnections,
|
|
5
|
+
normalizeSnowflakePrivateKey,
|
|
6
|
+
} from "./connection_config";
|
|
3
7
|
import { components } from "../api";
|
|
4
8
|
|
|
5
9
|
type ApiConnection = components["schemas"]["Connection"];
|
|
@@ -121,3 +125,44 @@ describe("assembleProjectConnections — databricks", () => {
|
|
|
121
125
|
);
|
|
122
126
|
});
|
|
123
127
|
});
|
|
128
|
+
|
|
129
|
+
describe("normalizeSnowflakePrivateKey", () => {
|
|
130
|
+
const { privateKey: pkcs8Pem } = generateKeyPairSync("rsa", {
|
|
131
|
+
modulusLength: 2048,
|
|
132
|
+
privateKeyEncoding: { type: "pkcs8", format: "pem" },
|
|
133
|
+
publicKeyEncoding: { type: "spki", format: "pem" },
|
|
134
|
+
});
|
|
135
|
+
const { privateKey: pkcs1Pem } = generateKeyPairSync("rsa", {
|
|
136
|
+
modulusLength: 2048,
|
|
137
|
+
privateKeyEncoding: { type: "pkcs1", format: "pem" },
|
|
138
|
+
publicKeyEncoding: { type: "pkcs1", format: "pem" },
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("passes a multi-line PKCS#8 key through and adds a trailing newline", () => {
|
|
142
|
+
const trimmed = (pkcs8Pem as string).trimEnd();
|
|
143
|
+
const result = normalizeSnowflakePrivateKey(trimmed);
|
|
144
|
+
expect(result).toContain("-----BEGIN PRIVATE KEY-----");
|
|
145
|
+
expect(result.endsWith("\n")).toBe(true);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("converts a multi-line PKCS#1 RSA key to PKCS#8", () => {
|
|
149
|
+
const result = normalizeSnowflakePrivateKey(pkcs1Pem as string);
|
|
150
|
+
expect(result).toContain("-----BEGIN PRIVATE KEY-----");
|
|
151
|
+
expect(result).not.toContain("BEGIN RSA PRIVATE KEY");
|
|
152
|
+
expect(result.endsWith("\n")).toBe(true);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("converts a single-line PKCS#1 RSA key to PKCS#8", () => {
|
|
156
|
+
const singleLine = (pkcs1Pem as string).replace(/\n/g, "");
|
|
157
|
+
const result = normalizeSnowflakePrivateKey(singleLine);
|
|
158
|
+
expect(result).toContain("-----BEGIN PRIVATE KEY-----");
|
|
159
|
+
expect(result).not.toContain("BEGIN RSA PRIVATE KEY");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("reconstructs a single-line PKCS#8 key without conversion", () => {
|
|
163
|
+
const singleLine = (pkcs8Pem as string).replace(/\n/g, "");
|
|
164
|
+
const result = normalizeSnowflakePrivateKey(singleLine);
|
|
165
|
+
expect(result.startsWith("-----BEGIN PRIVATE KEY-----\n")).toBe(true);
|
|
166
|
+
expect(result.endsWith("-----END PRIVATE KEY-----\n")).toBe(true);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createPrivateKey } from "crypto";
|
|
1
2
|
import path from "path";
|
|
2
3
|
import { components } from "../api";
|
|
3
4
|
|
|
@@ -48,6 +49,12 @@ export function normalizeSnowflakePrivateKey(privateKey: string): string {
|
|
|
48
49
|
beginMarker: "-----BEGIN PRIVATE KEY-----",
|
|
49
50
|
endMarker: "-----END PRIVATE KEY-----",
|
|
50
51
|
},
|
|
52
|
+
{
|
|
53
|
+
beginRegex: /-----BEGIN\s+RSA\s+PRIVATE\s+KEY-----/i,
|
|
54
|
+
endRegex: /-----END\s+RSA\s+PRIVATE\s+KEY-----/i,
|
|
55
|
+
beginMarker: "-----BEGIN RSA PRIVATE KEY-----",
|
|
56
|
+
endMarker: "-----END RSA PRIVATE KEY-----",
|
|
57
|
+
},
|
|
51
58
|
];
|
|
52
59
|
|
|
53
60
|
for (const pattern of keyPatterns) {
|
|
@@ -73,6 +80,28 @@ export function normalizeSnowflakePrivateKey(privateKey: string): string {
|
|
|
73
80
|
privateKeyContent += "\n";
|
|
74
81
|
}
|
|
75
82
|
|
|
83
|
+
// Snowflake's Node SDK requires PKCS#8 ("BEGIN PRIVATE KEY"). Convert
|
|
84
|
+
// PKCS#1 ("BEGIN RSA PRIVATE KEY") so users can paste either format.
|
|
85
|
+
if (/-----BEGIN\s+RSA\s+PRIVATE\s+KEY-----/i.test(privateKeyContent)) {
|
|
86
|
+
try {
|
|
87
|
+
privateKeyContent = createPrivateKey({
|
|
88
|
+
key: privateKeyContent,
|
|
89
|
+
format: "pem",
|
|
90
|
+
})
|
|
91
|
+
.export({ type: "pkcs8", format: "pem" })
|
|
92
|
+
.toString();
|
|
93
|
+
} catch (err) {
|
|
94
|
+
throw new Error(
|
|
95
|
+
`Failed to convert Snowflake RSA private key (PKCS#1) to PKCS#8: ${
|
|
96
|
+
err instanceof Error ? err.message : String(err)
|
|
97
|
+
}`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
if (!privateKeyContent.endsWith("\n")) {
|
|
101
|
+
privateKeyContent += "\n";
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
76
105
|
return privateKeyContent;
|
|
77
106
|
}
|
|
78
107
|
|
package/src/service/model.ts
CHANGED
|
@@ -22,10 +22,10 @@ import {
|
|
|
22
22
|
MalloySQLParser,
|
|
23
23
|
MalloySQLStatementType,
|
|
24
24
|
} from "@malloydata/malloy-sql";
|
|
25
|
-
import { createRequire } from "module";
|
|
26
25
|
import { DataStyles } from "@malloydata/render";
|
|
27
26
|
import { metrics } from "@opentelemetry/api";
|
|
28
27
|
import * as fs from "fs/promises";
|
|
28
|
+
import { createRequire } from "module";
|
|
29
29
|
import * as path from "path";
|
|
30
30
|
import { components } from "../api";
|
|
31
31
|
import {
|
|
@@ -703,7 +703,7 @@ export class Model {
|
|
|
703
703
|
|
|
704
704
|
const modelURL = new URL(`file://${fullModelPath}`);
|
|
705
705
|
const baseUrl = new URL(".", modelURL);
|
|
706
|
-
const importBaseURL =
|
|
706
|
+
const importBaseURL = baseUrl;
|
|
707
707
|
const urlReader = new HackyDataStylesAccumulator(URL_READER);
|
|
708
708
|
|
|
709
709
|
// Request runtimes borrow the cached package MalloyConfig. The package
|
package/src/service/project.ts
CHANGED
|
@@ -99,6 +99,10 @@ export class Project {
|
|
|
99
99
|
await this.writeProjectReadme(payload.readme);
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
if (payload.materializationStorage !== undefined) {
|
|
103
|
+
this.metadata.materializationStorage = payload.materializationStorage;
|
|
104
|
+
}
|
|
105
|
+
|
|
102
106
|
// Handle connections update
|
|
103
107
|
// TODO: Update project connections should have its own API endpoint
|
|
104
108
|
if (payload.connections) {
|
|
@@ -10,6 +10,12 @@ import { ProjectStore } from "./project_store";
|
|
|
10
10
|
|
|
11
11
|
type MockData = Record<string, unknown>;
|
|
12
12
|
|
|
13
|
+
const initializeDuckLakeCalls: Array<{
|
|
14
|
+
projectId: string;
|
|
15
|
+
projectName: string;
|
|
16
|
+
config: { catalogUrl: string; dataPath: string };
|
|
17
|
+
}> = [];
|
|
18
|
+
|
|
13
19
|
mock.module("../storage/StorageManager", () => {
|
|
14
20
|
return {
|
|
15
21
|
StorageManager: class MockStorageManager {
|
|
@@ -17,6 +23,14 @@ mock.module("../storage/StorageManager", () => {
|
|
|
17
23
|
return;
|
|
18
24
|
}
|
|
19
25
|
|
|
26
|
+
async initializeDuckLakeForProject(
|
|
27
|
+
projectId: string,
|
|
28
|
+
projectName: string,
|
|
29
|
+
config: { catalogUrl: string; dataPath: string },
|
|
30
|
+
): Promise<void> {
|
|
31
|
+
initializeDuckLakeCalls.push({ projectId, projectName, config });
|
|
32
|
+
}
|
|
33
|
+
|
|
20
34
|
getRepository() {
|
|
21
35
|
return {
|
|
22
36
|
// ===== PROJECT METHODS =====
|
|
@@ -467,6 +481,69 @@ describe("ProjectStore Service", () => {
|
|
|
467
481
|
expect(readmeContent).toBe("Updated README content");
|
|
468
482
|
});
|
|
469
483
|
|
|
484
|
+
it("should propagate materializationStorage on addProject for new project", async () => {
|
|
485
|
+
writeFileSync(
|
|
486
|
+
path.join(serverRootPath, "publisher.config.json"),
|
|
487
|
+
JSON.stringify({ frozenConfig: false, projects: [] }),
|
|
488
|
+
);
|
|
489
|
+
|
|
490
|
+
await projectStore.finishedInitialization;
|
|
491
|
+
|
|
492
|
+
const materializationStorage = {
|
|
493
|
+
catalogUrl:
|
|
494
|
+
"postgres:host=localhost port=5432 dbname=ducklake user=u password=p",
|
|
495
|
+
dataPath: "gs://test-bucket",
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
initializeDuckLakeCalls.length = 0;
|
|
499
|
+
const project = await projectStore.addProject({
|
|
500
|
+
name: projectName,
|
|
501
|
+
materializationStorage,
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
expect(project.metadata.materializationStorage).toEqual(
|
|
505
|
+
materializationStorage,
|
|
506
|
+
);
|
|
507
|
+
expect(initializeDuckLakeCalls).toHaveLength(1);
|
|
508
|
+
expect(initializeDuckLakeCalls[0].config).toEqual(materializationStorage);
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
it("should propagate materializationStorage on update", async () => {
|
|
512
|
+
const projectPath = path.join(serverRootPath, projectName);
|
|
513
|
+
mkdirSync(projectPath, { recursive: true });
|
|
514
|
+
writeFileSync(
|
|
515
|
+
path.join(projectPath, "publisher.json"),
|
|
516
|
+
JSON.stringify({ name: projectName, description: "Test package" }),
|
|
517
|
+
);
|
|
518
|
+
writeFileSync(
|
|
519
|
+
path.join(serverRootPath, "publisher.config.json"),
|
|
520
|
+
JSON.stringify({
|
|
521
|
+
frozenConfig: false,
|
|
522
|
+
projects: [
|
|
523
|
+
{
|
|
524
|
+
name: projectName,
|
|
525
|
+
packages: [{ name: projectName, location: projectPath }],
|
|
526
|
+
},
|
|
527
|
+
],
|
|
528
|
+
}),
|
|
529
|
+
);
|
|
530
|
+
|
|
531
|
+
await projectStore.finishedInitialization;
|
|
532
|
+
const project = await projectStore.getProject(projectName);
|
|
533
|
+
|
|
534
|
+
const materializationStorage = {
|
|
535
|
+
catalogUrl:
|
|
536
|
+
"postgres:host=localhost port=5432 dbname=ducklake user=u password=p",
|
|
537
|
+
dataPath: "gs://test-bucket",
|
|
538
|
+
};
|
|
539
|
+
|
|
540
|
+
await project.update({ name: projectName, materializationStorage });
|
|
541
|
+
|
|
542
|
+
expect(project.metadata.materializationStorage).toEqual(
|
|
543
|
+
materializationStorage,
|
|
544
|
+
);
|
|
545
|
+
});
|
|
546
|
+
|
|
470
547
|
it(
|
|
471
548
|
"should handle project reload",
|
|
472
549
|
async () => {
|
|
@@ -359,10 +359,14 @@ export class ProjectStore {
|
|
|
359
359
|
materializationStorage?.catalogUrl &&
|
|
360
360
|
materializationStorage?.dataPath
|
|
361
361
|
) {
|
|
362
|
-
await this.storageManager.initializeDuckLakeForProject(
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
362
|
+
await this.storageManager.initializeDuckLakeForProject(
|
|
363
|
+
dbProject.id,
|
|
364
|
+
dbProject.name,
|
|
365
|
+
{
|
|
366
|
+
catalogUrl: materializationStorage.catalogUrl,
|
|
367
|
+
dataPath: materializationStorage.dataPath,
|
|
368
|
+
},
|
|
369
|
+
);
|
|
366
370
|
}
|
|
367
371
|
|
|
368
372
|
return dbProject;
|
|
@@ -807,6 +811,10 @@ export class ProjectStore {
|
|
|
807
811
|
|
|
808
812
|
if (!newProject.metadata) newProject.metadata = {};
|
|
809
813
|
newProject.metadata.location = absoluteProjectPath;
|
|
814
|
+
if (project.materializationStorage !== undefined) {
|
|
815
|
+
newProject.metadata.materializationStorage =
|
|
816
|
+
project.materializationStorage;
|
|
817
|
+
}
|
|
810
818
|
|
|
811
819
|
this.projects.set(projectName, newProject);
|
|
812
820
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import * as crypto from "crypto";
|
|
1
2
|
import { logger } from "../logger";
|
|
2
3
|
import {
|
|
3
4
|
DatabaseConnection,
|
|
@@ -42,6 +43,19 @@ function escapeSQL(value: string): string {
|
|
|
42
43
|
return value.replace(/'/g, "''");
|
|
43
44
|
}
|
|
44
45
|
|
|
46
|
+
function configKey(c: DuckLakeManifestConfig): string {
|
|
47
|
+
return `${c.catalogUrl}|${c.dataPath}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function catalogNameForConfig(c: DuckLakeManifestConfig): string {
|
|
51
|
+
const hash = crypto
|
|
52
|
+
.createHash("sha256")
|
|
53
|
+
.update(configKey(c))
|
|
54
|
+
.digest("hex")
|
|
55
|
+
.slice(0, 8);
|
|
56
|
+
return `manifest_lake_${hash}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
45
59
|
/**
|
|
46
60
|
* Manages the storage backend (DuckDB, Postgres, etc.) and per-project
|
|
47
61
|
* manifest stores. Projects without `materializationStorage` config use
|
|
@@ -57,8 +71,12 @@ export class StorageManager {
|
|
|
57
71
|
/** Per-project DuckLake manifest stores, keyed by projectId. */
|
|
58
72
|
private projectManifestStores = new Map<string, ManifestStore>();
|
|
59
73
|
|
|
60
|
-
/**
|
|
61
|
-
|
|
74
|
+
/**
|
|
75
|
+
* Tracks attached DuckLake catalogs as `configKey -> catalogName`. Each
|
|
76
|
+
* unique materializationStorage config gets its own ATTACHment under a
|
|
77
|
+
* deterministic catalog name, so multiple configs can coexist on one worker.
|
|
78
|
+
*/
|
|
79
|
+
private attachedCatalogs = new Map<string, string>();
|
|
62
80
|
|
|
63
81
|
private config: StorageConfig;
|
|
64
82
|
|
|
@@ -105,32 +123,44 @@ export class StorageManager {
|
|
|
105
123
|
|
|
106
124
|
/**
|
|
107
125
|
* Lazily initializes a DuckLake manifest store for a project.
|
|
108
|
-
*
|
|
109
|
-
*
|
|
126
|
+
*
|
|
127
|
+
* One shared catalog per materializationStorage config: every project
|
|
128
|
+
* pointing at the same (catalogUrl, dataPath) shares one `build_manifests`
|
|
129
|
+
* table inside it, partitioned by `project_id` (set to the project's name
|
|
130
|
+
* so it's stable across worker replicas — required for cross-pod manifest
|
|
131
|
+
* visibility in orchestrated mode). Different configs (e.g. different
|
|
132
|
+
* orgs) attach as separate catalogs under distinct deterministic aliases.
|
|
110
133
|
*/
|
|
111
134
|
async initializeDuckLakeForProject(
|
|
112
135
|
projectId: string,
|
|
136
|
+
projectName: string,
|
|
113
137
|
config: DuckLakeManifestConfig,
|
|
114
138
|
): Promise<void> {
|
|
115
139
|
if (!this.duckDbConnection) {
|
|
116
140
|
throw new Error("Storage not initialized. Call initialize() first.");
|
|
117
141
|
}
|
|
118
142
|
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
if (!
|
|
143
|
+
const key = configKey(config);
|
|
144
|
+
let catalogName = this.attachedCatalogs.get(key);
|
|
145
|
+
if (!catalogName) {
|
|
146
|
+
// Catalog name derived from the config so multiple configs can coexist as
|
|
147
|
+
// separate ATTACHments without colliding on the name.
|
|
148
|
+
catalogName = catalogNameForConfig(config);
|
|
122
149
|
await this.attachDuckLakeCatalog(config, catalogName);
|
|
150
|
+
this.attachedCatalogs.set(key, catalogName);
|
|
123
151
|
}
|
|
124
152
|
|
|
125
153
|
const store = new DuckLakeManifestStore(
|
|
126
154
|
this.duckDbConnection,
|
|
127
155
|
catalogName,
|
|
156
|
+
projectName,
|
|
128
157
|
);
|
|
129
158
|
await store.bootstrapSchema();
|
|
130
159
|
|
|
131
160
|
this.projectManifestStores.set(projectId, store);
|
|
132
161
|
logger.info("DuckLake manifest store initialized for project", {
|
|
133
162
|
projectId,
|
|
163
|
+
projectName,
|
|
134
164
|
catalogName,
|
|
135
165
|
});
|
|
136
166
|
}
|
|
@@ -155,7 +185,14 @@ export class StorageManager {
|
|
|
155
185
|
config.dataPath.startsWith("s3://");
|
|
156
186
|
|
|
157
187
|
let attachCmd = `ATTACH 'ducklake:${escapedCatalogUrl}' AS ${catalogName}`;
|
|
158
|
-
const attachOpts: string[] = [
|
|
188
|
+
const attachOpts: string[] = [
|
|
189
|
+
`DATA_PATH '${escapedDataPath}'`,
|
|
190
|
+
// The manifest table is small relational metadata (one row per build).
|
|
191
|
+
// Set a high inlining limit so writes always land transactionally in
|
|
192
|
+
// the postgres catalog rather than as parquet files in object storage,
|
|
193
|
+
// sidestepping object-storage auth issues entirely for this path.
|
|
194
|
+
"DATA_INLINING_ROW_LIMIT 100000",
|
|
195
|
+
];
|
|
159
196
|
if (isCloudStorage) {
|
|
160
197
|
attachOpts.push("OVERRIDE_DATA_PATH true");
|
|
161
198
|
}
|
|
@@ -163,8 +200,6 @@ export class StorageManager {
|
|
|
163
200
|
|
|
164
201
|
logger.info(`Attaching DuckLake manifest catalog: ${attachCmd}`);
|
|
165
202
|
await connection.run(attachCmd);
|
|
166
|
-
|
|
167
|
-
this.attachedCatalogs.add(catalogName);
|
|
168
203
|
}
|
|
169
204
|
|
|
170
205
|
getRepository(): ResourceRepository {
|
|
@@ -31,23 +31,32 @@ import { DuckDBConnection } from "../duckdb/DuckDBConnection";
|
|
|
31
31
|
*/
|
|
32
32
|
export class DuckLakeManifestStore implements ManifestStore {
|
|
33
33
|
private readonly table: string;
|
|
34
|
+
private readonly projectName: string;
|
|
34
35
|
|
|
35
36
|
constructor(
|
|
36
37
|
private db: DuckDBConnection,
|
|
37
38
|
catalogName: string,
|
|
39
|
+
projectName: string,
|
|
38
40
|
) {
|
|
39
41
|
this.table = `${catalogName}.build_manifests`;
|
|
42
|
+
this.projectName = projectName;
|
|
40
43
|
}
|
|
41
44
|
|
|
42
45
|
/**
|
|
43
46
|
* Idempotently creates the `build_manifests` table and indices in the
|
|
44
47
|
* DuckLake catalog. Safe to call from every worker on startup.
|
|
48
|
+
*
|
|
49
|
+
* Note: this table uses `project_name` for the partition column, unlike
|
|
50
|
+
* the local DuckDB `build_manifests` table (in `storage/duckdb/schema.ts`)
|
|
51
|
+
* which uses `project_id` and FK-references `projects.id`. The two stores
|
|
52
|
+
* partition by different identifiers — local random id vs cross-pod-stable
|
|
53
|
+
* project name — so they intentionally diverge here.
|
|
45
54
|
*/
|
|
46
55
|
async bootstrapSchema(): Promise<void> {
|
|
47
56
|
await this.db.run(`
|
|
48
57
|
CREATE TABLE IF NOT EXISTS ${this.table} (
|
|
49
58
|
id VARCHAR,
|
|
50
|
-
|
|
59
|
+
project_name VARCHAR NOT NULL,
|
|
51
60
|
package_name VARCHAR NOT NULL,
|
|
52
61
|
build_id VARCHAR NOT NULL,
|
|
53
62
|
table_name VARCHAR NOT NULL,
|
|
@@ -61,12 +70,12 @@ export class DuckLakeManifestStore implements ManifestStore {
|
|
|
61
70
|
}
|
|
62
71
|
|
|
63
72
|
async getManifest(
|
|
64
|
-
|
|
73
|
+
_projectId: string,
|
|
65
74
|
packageName: string,
|
|
66
75
|
): Promise<BuildManifest> {
|
|
67
76
|
const rows = await this.db.all<Record<string, unknown>>(
|
|
68
|
-
`SELECT * FROM ${this.table} WHERE
|
|
69
|
-
[
|
|
77
|
+
`SELECT * FROM ${this.table} WHERE project_name = ? AND package_name = ? ORDER BY created_at DESC`,
|
|
78
|
+
[this.projectName, packageName],
|
|
70
79
|
);
|
|
71
80
|
const manifest: BuildManifest = { entries: {}, strict: false };
|
|
72
81
|
for (const row of rows) {
|
|
@@ -88,7 +97,7 @@ export class DuckLakeManifestStore implements ManifestStore {
|
|
|
88
97
|
* {@link getManifest} deduplicates by build_id keeping the newest row.
|
|
89
98
|
*/
|
|
90
99
|
async writeEntry(
|
|
91
|
-
|
|
100
|
+
_projectId: string,
|
|
92
101
|
packageName: string,
|
|
93
102
|
buildId: string,
|
|
94
103
|
tableName: string,
|
|
@@ -99,11 +108,11 @@ export class DuckLakeManifestStore implements ManifestStore {
|
|
|
99
108
|
const id = `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
|
|
100
109
|
|
|
101
110
|
await this.db.run(
|
|
102
|
-
`INSERT INTO ${this.table} (id,
|
|
111
|
+
`INSERT INTO ${this.table} (id, project_name, package_name, build_id, table_name, source_name, connection_name, created_at, updated_at)
|
|
103
112
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
104
113
|
[
|
|
105
114
|
id,
|
|
106
|
-
|
|
115
|
+
this.projectName,
|
|
107
116
|
packageName,
|
|
108
117
|
buildId,
|
|
109
118
|
tableName,
|
|
@@ -120,12 +129,12 @@ export class DuckLakeManifestStore implements ManifestStore {
|
|
|
120
129
|
}
|
|
121
130
|
|
|
122
131
|
async listEntries(
|
|
123
|
-
|
|
132
|
+
_projectId: string,
|
|
124
133
|
packageName: string,
|
|
125
134
|
): Promise<ManifestEntry[]> {
|
|
126
135
|
const rows = await this.db.all<Record<string, unknown>>(
|
|
127
|
-
`SELECT * FROM ${this.table} WHERE
|
|
128
|
-
[
|
|
136
|
+
`SELECT * FROM ${this.table} WHERE project_name = ? AND package_name = ? ORDER BY created_at DESC`,
|
|
137
|
+
[this.projectName, packageName],
|
|
129
138
|
);
|
|
130
139
|
return rows.map(this.mapToEntry);
|
|
131
140
|
}
|
|
@@ -133,7 +142,7 @@ export class DuckLakeManifestStore implements ManifestStore {
|
|
|
133
142
|
private mapToEntry(row: Record<string, unknown>): ManifestEntry {
|
|
134
143
|
return {
|
|
135
144
|
id: row.id as string,
|
|
136
|
-
projectId: row.
|
|
145
|
+
projectId: row.project_name as string,
|
|
137
146
|
packageName: row.package_name as string,
|
|
138
147
|
buildId: row.build_id as string,
|
|
139
148
|
tableName: row.table_name as string,
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
Request,
|
|
6
6
|
Result,
|
|
7
7
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
8
|
+
import { Mutex } from "async-mutex";
|
|
8
9
|
import http from "http";
|
|
9
10
|
import { AddressInfo } from "net";
|
|
10
11
|
import path from "path";
|
|
@@ -24,37 +25,24 @@ export interface McpE2ETestEnvironment {
|
|
|
24
25
|
originalInitializeStorage: string | undefined;
|
|
25
26
|
}
|
|
26
27
|
|
|
27
|
-
// Counter for unique port assignment per test suite
|
|
28
|
+
// Counter for unique port assignment per test suite (incremented only under e2eSetupMutex)
|
|
28
29
|
let portCounter = 0;
|
|
29
30
|
|
|
30
|
-
//
|
|
31
|
-
|
|
31
|
+
// True mutex: the previous Promise-based lock had a TOCTOU race where two beforeAll hooks
|
|
32
|
+
// could both pass the `if (initializationLock)` check and run setup concurrently, sharing
|
|
33
|
+
// one publisher.db / server singleton and causing flaky listResources and port collisions.
|
|
34
|
+
const e2eSetupMutex = new Mutex();
|
|
32
35
|
|
|
33
36
|
/**
|
|
34
37
|
* Starts the real application server and connects a real MCP client.
|
|
35
38
|
*/
|
|
36
39
|
export async function setupE2ETestEnvironment(): Promise<McpE2ETestEnvironment> {
|
|
37
|
-
|
|
38
|
-
if (initializationLock) {
|
|
40
|
+
return e2eSetupMutex.runExclusive(async () => {
|
|
39
41
|
console.log(
|
|
40
|
-
"[E2E Test Setup]
|
|
42
|
+
"[E2E Test Setup] Acquired setup mutex; starting environment...",
|
|
41
43
|
);
|
|
42
|
-
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
// Create a new lock for this initialization
|
|
46
|
-
let resolveLock: () => void;
|
|
47
|
-
initializationLock = new Promise((resolve) => {
|
|
48
|
-
resolveLock = resolve;
|
|
44
|
+
return setupE2ETestEnvironmentInternal();
|
|
49
45
|
});
|
|
50
|
-
|
|
51
|
-
try {
|
|
52
|
-
return await setupE2ETestEnvironmentInternal();
|
|
53
|
-
} finally {
|
|
54
|
-
// Release the lock
|
|
55
|
-
resolveLock!();
|
|
56
|
-
initializationLock = null;
|
|
57
|
-
}
|
|
58
46
|
}
|
|
59
47
|
|
|
60
48
|
async function setupE2ETestEnvironmentInternal(): Promise<McpE2ETestEnvironment> {
|
|
@@ -129,9 +117,9 @@ async function setupE2ETestEnvironmentInternal(): Promise<McpE2ETestEnvironment>
|
|
|
129
117
|
|
|
130
118
|
// --- Wait for server to be ready (packages downloaded) ---
|
|
131
119
|
// Poll the readiness endpoint to ensure initialization completes before tests run
|
|
132
|
-
//
|
|
120
|
+
// Allow >90s: cold CI can spend most of the budget on clone + package load before markReady().
|
|
133
121
|
console.log("[E2E Test Setup] Waiting for server to be ready...");
|
|
134
|
-
const maxWaitTime =
|
|
122
|
+
const maxWaitTime = 150000; // 150 seconds (INITIALIZE_STORAGE + large samples can be slow)
|
|
135
123
|
const pollInterval = 1000; // Check every second
|
|
136
124
|
const startTime = Date.now();
|
|
137
125
|
let isReady = false;
|
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
} from "../../harness/mcp_test_setup";
|
|
20
20
|
|
|
21
21
|
// --- Test Suite ---
|
|
22
|
-
describe("MCP Tool Handlers (E2E Integration)", () => {
|
|
22
|
+
describe.serial("MCP Tool Handlers (E2E Integration)", () => {
|
|
23
23
|
let env: McpE2ETestEnvironment | null = null;
|
|
24
24
|
let mcpClient: Client;
|
|
25
25
|
|
|
@@ -20,7 +20,8 @@ interface PackageContentEntry {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
// --- Test Suite ---
|
|
23
|
-
|
|
23
|
+
// Serial: one shared MCP client; concurrent it() blocks can interleave StreamableHTTP requests.
|
|
24
|
+
describe.serial("MCP Resource Handlers (E2E Integration)", () => {
|
|
24
25
|
let env: McpE2ETestEnvironment | null = null;
|
|
25
26
|
let mcpClient: Client;
|
|
26
27
|
|
|
@@ -85,17 +86,17 @@ describe("MCP Resource Handlers (E2E Integration)", () => {
|
|
|
85
86
|
expect(faaPackageEntry).toBeDefined();
|
|
86
87
|
expect(faaPackageEntry?.name).toBe("faa");
|
|
87
88
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
expect(
|
|
91
|
-
expect(typeof
|
|
92
|
-
expect(
|
|
93
|
-
expect(
|
|
94
|
-
expect(typeof
|
|
95
|
-
expect(
|
|
96
|
-
expect(typeof
|
|
97
|
-
if (
|
|
98
|
-
expect(typeof
|
|
89
|
+
// Assert shape on a known row (order of listResources is not guaranteed).
|
|
90
|
+
const sample = faaPackageEntry!;
|
|
91
|
+
expect(sample.uri).toBeDefined();
|
|
92
|
+
expect(typeof sample.uri).toBe("string");
|
|
93
|
+
expect(sample.uri).toMatch(/^malloy:\/\//);
|
|
94
|
+
expect(sample.name).toBeDefined();
|
|
95
|
+
expect(typeof sample.name).toBe("string");
|
|
96
|
+
expect(sample.description).toBeDefined();
|
|
97
|
+
expect(typeof sample.description).toBe("string");
|
|
98
|
+
if (sample.mimeType) {
|
|
99
|
+
expect(typeof sample.mimeType).toBe("string");
|
|
99
100
|
}
|
|
100
101
|
},
|
|
101
102
|
{ timeout: 30000 },
|
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
// --- Test Suite ---
|
|
19
19
|
// Note: These tests assume interaction via a standard MCP client.
|
|
20
20
|
// Tests for raw HTTP edge cases (non-JSON, non-RPC) are omitted based on this assumption.
|
|
21
|
-
describe("MCP Transport Tests (E2E Integration)", () => {
|
|
21
|
+
describe.serial("MCP Transport Tests (E2E Integration)", () => {
|
|
22
22
|
let env: McpE2ETestEnvironment | null = null;
|
|
23
23
|
let mcpClient: Client<Request, Notification, Result>; // Convenience variable
|
|
24
24
|
|