@happyvertical/smrt-dev-mcp 0.47.0 → 0.47.2

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/index.js CHANGED
@@ -1,10 +1,18 @@
1
1
  #!/usr/bin/env node
2
- import { a as checkKnowledgeFreshness, f as smrtArchitecture, h as TOOLS, i as buildReviewContext, m as REVIEW_SKILL_NAME, n as buildKnowledgeIndex, o as checkKnowledgeFreshnessFromIndex, p as smrtReview, r as buildPackageSpecialistContext, s as compactContextResult, t as buildArchitectureContext } from "./knowledge-SqpqYA67.js";
2
+ import { a as checkKnowledgeFreshness, f as smrtArchitecture, h as TOOLS, i as buildReviewContext, m as REVIEW_SKILL_NAME, n as buildKnowledgeIndex, o as checkKnowledgeFreshnessFromIndex, p as smrtReview, r as buildPackageSpecialistContext, s as compactContextResult, t as buildArchitectureContext } from "./knowledge-CY6sQzpj.js";
3
3
  import { existsSync, readFileSync, realpathSync } from "node:fs";
4
- import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
4
  import { fileURLToPath, pathToFileURL } from "node:url";
6
- import { ProtocolError, ProtocolErrorCode, Server } from "@modelcontextprotocol/server";
5
+ import { ProtocolError, ProtocolErrorCode, Server, createMcpHandler } from "@modelcontextprotocol/server";
7
6
  import { serveStdio } from "@modelcontextprotocol/server/stdio";
7
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
8
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
9
+ import { createServer as createServer$1 } from "node:http";
10
+ import { localhostHostValidation, localhostOriginValidation, toNodeHandler } from "@modelcontextprotocol/node";
11
+ import { ObjectRegistry, readDispatchHealth, readJobHealth, readMigrationStatus, readRecentChanges, readRegistryDrift, readScheduleHealth, snapshotRegistry } from "@happyvertical/smrt-core";
12
+ import { discoverSmrtPackages, resolveManifestPath } from "@happyvertical/smrt-core/manifest/discover-smrt-packages";
13
+ import { SchemaComparer } from "@happyvertical/smrt-core/migrations";
14
+ import { getPackageConfig, loadConfig } from "@happyvertical/smrt-config";
15
+ import { getDatabase } from "@happyvertical/sql";
8
16
  import { ManifestAdapter, OxcScanner } from "@happyvertical/smrt-scanner";
9
17
  import { access, readFile, readdir } from "node:fs/promises";
10
18
  import { ManifestGenerator } from "@happyvertical/smrt-core/scanner";
@@ -43,6 +51,828 @@ function resolvePackageRoot() {
43
51
  return packageRoot;
44
52
  }
45
53
  //#endregion
54
+ //#region src/server-info.ts
55
+ var SERVER_NAME = "smrt-dev-mcp";
56
+ function readPackageVersion() {
57
+ try {
58
+ const packageJsonPath = join(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
59
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
60
+ return typeof packageJson.version === "string" ? packageJson.version : "0.0.0";
61
+ } catch {
62
+ return "0.0.0";
63
+ }
64
+ }
65
+ var SERVER_VERSION = readPackageVersion();
66
+ //#endregion
67
+ //#region src/tools/runtime/boot.ts
68
+ /**
69
+ * Confined runtime bootstrap for the Level 2 observation plane (#1831).
70
+ *
71
+ * "Booting" here means registering *manifests* into the in-process
72
+ * `ObjectRegistry` — the project's own `.smrt/manifest.json` (or built
73
+ * `dist/manifest.json`) plus every installed SMRT package manifest that the
74
+ * project's dependency tree resolves to. No project source is imported, no
75
+ * module is executed, no database is touched. That confinement is the safety
76
+ * boundary: an observing agent sees what the runtime *would* register, never
77
+ * what arbitrary project code does on import.
78
+ *
79
+ * The registry is a process-global singleton, so a process boots once. There
80
+ * is deliberately no re-boot tool; restart the process to observe a rebuilt
81
+ * manifest.
82
+ */
83
+ /** Provenance label for facts read from authored/installed manifests. */
84
+ var DECLARED_PROVENANCE = "declared (manifest)";
85
+ /** Project manifest candidates, most authoritative first. */
86
+ var PROJECT_MANIFEST_CANDIDATES = [".smrt/manifest.json", "dist/manifest.json"];
87
+ function relativePath(projectRoot, path) {
88
+ const rel = relative(projectRoot, path);
89
+ if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return basename(path);
90
+ return rel.split(sep).join("/");
91
+ }
92
+ function readManifest(path) {
93
+ try {
94
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
95
+ return parsed && typeof parsed === "object" ? parsed : null;
96
+ } catch {
97
+ return null;
98
+ }
99
+ }
100
+ function registerManifest(manifest, fallbackPackageName) {
101
+ const manifestPackageName = typeof manifest.packageName === "string" && manifest.packageName ? manifest.packageName : fallbackPackageName ?? void 0;
102
+ let count = 0;
103
+ for (const [name, definition] of Object.entries(manifest.objects ?? {})) {
104
+ if (!definition || typeof definition !== "object") continue;
105
+ const ownPackage = definition.packageName;
106
+ ObjectRegistry.registerFromManifest(name, definition, typeof ownPackage === "string" && ownPackage ? ownPackage : manifestPackageName);
107
+ count += 1;
108
+ }
109
+ return count;
110
+ }
111
+ var booted = null;
112
+ var bootedProjectRoot = null;
113
+ /**
114
+ * The resolved root the process booted from. Consumers must relativize
115
+ * paths against *this* root, never a per-request argument, or a caller could
116
+ * widen the root (e.g. `/`) and read the layout back through "relative" paths.
117
+ */
118
+ function getBootedProjectRoot() {
119
+ return bootedProjectRoot;
120
+ }
121
+ /**
122
+ * Boot the confined runtime once per process. A second call returns the
123
+ * existing record without touching the registry.
124
+ */
125
+ async function bootRuntime(options = {}) {
126
+ if (booted) return booted;
127
+ const projectRoot = resolve(options.projectRoot ?? process.cwd());
128
+ const diagnostics = [];
129
+ const manifests = [];
130
+ const projectManifestPath = PROJECT_MANIFEST_CANDIDATES.map((candidate) => join(projectRoot, candidate)).find((path) => existsSync(path));
131
+ const projectPackageName = readProjectPackageName(projectRoot);
132
+ if (!projectManifestPath) diagnostics.push({
133
+ severity: "warning",
134
+ code: "project_manifest_missing",
135
+ message: "No project manifest found (.smrt/manifest.json or dist/manifest.json); run the project build so the runtime manifest exists."
136
+ });
137
+ else {
138
+ const manifest = readManifest(projectManifestPath);
139
+ if (!manifest) diagnostics.push({
140
+ severity: "error",
141
+ code: "project_manifest_invalid",
142
+ message: `Project manifest at ${relativePath(projectRoot, projectManifestPath)} is not valid JSON.`
143
+ });
144
+ else manifests.push({
145
+ kind: "project",
146
+ packageName: typeof manifest.packageName === "string" ? manifest.packageName : projectPackageName,
147
+ path: relativePath(projectRoot, projectManifestPath),
148
+ objectCount: registerManifest(manifest, projectPackageName)
149
+ });
150
+ }
151
+ let dependencyNames = [];
152
+ try {
153
+ dependencyNames = discoverSmrtPackages({
154
+ baseDir: projectRoot,
155
+ noCache: true
156
+ });
157
+ } catch (error) {
158
+ diagnostics.push({
159
+ severity: "warning",
160
+ code: "dependency_discovery_failed",
161
+ message: `Installed SMRT package discovery failed: ${error instanceof Error ? error.message : "unknown error"}`
162
+ });
163
+ }
164
+ for (const dependency of dependencyNames.sort()) {
165
+ const manifestPath = resolveManifestPath(dependency, projectRoot);
166
+ if (!manifestPath) {
167
+ diagnostics.push({
168
+ severity: "info",
169
+ code: "dependency_manifest_missing",
170
+ message: `Installed SMRT package ${dependency} exposes no runtime manifest.`
171
+ });
172
+ continue;
173
+ }
174
+ const manifest = readManifest(manifestPath);
175
+ if (!manifest) {
176
+ diagnostics.push({
177
+ severity: "warning",
178
+ code: "dependency_manifest_invalid",
179
+ message: `Manifest for ${dependency} is not valid JSON.`
180
+ });
181
+ continue;
182
+ }
183
+ manifests.push({
184
+ kind: "dependency",
185
+ packageName: dependency,
186
+ path: relativePath(projectRoot, manifestPath),
187
+ objectCount: registerManifest(manifest, dependency)
188
+ });
189
+ }
190
+ bootedProjectRoot = projectRoot;
191
+ booted = {
192
+ provenance: DECLARED_PROVENANCE,
193
+ bootedAt: (options.now ?? /* @__PURE__ */ new Date()).toISOString(),
194
+ projectName: projectPackageName ?? basename(projectRoot),
195
+ manifests,
196
+ objectCount: manifests.reduce((sum, m) => sum + m.objectCount, 0),
197
+ diagnostics
198
+ };
199
+ return booted;
200
+ }
201
+ function readProjectPackageName(projectRoot) {
202
+ try {
203
+ const parsed = JSON.parse(readFileSync(join(projectRoot, "package.json"), "utf8"));
204
+ return typeof parsed.name === "string" && parsed.name ? parsed.name : null;
205
+ } catch {
206
+ return null;
207
+ }
208
+ }
209
+ //#endregion
210
+ //#region src/tools/runtime/connection.ts
211
+ /**
212
+ * Optional read-only dev-database connection resolution for runtime
213
+ * diagnostics tools (#1824).
214
+ *
215
+ * Resolution order per call:
216
+ * 1. explicit `dbUrl`/`dbType` tool arguments
217
+ * 2. `SMRT_DEV_DB_URL` environment variable
218
+ * 3. the project's cosmiconfig CLI section (`getPackageConfig('cli', ...)`
219
+ * from `@happyvertical/smrt-config`) → `database.{type,url}`
220
+ *
221
+ * No configured connection → `db: null` with `source: 'none'`; callers return
222
+ * a successful static-only envelope. A connection is always opened lazily per
223
+ * call and closed in the caller's `finally` — nothing is cached across calls
224
+ * and the server never holds a database handle.
225
+ *
226
+ * Sensitive handling: connection strings are never logged or echoed. Every
227
+ * surfaced URL passes through {@link redactConnectionString}; driver errors
228
+ * are surfaced only through {@link safeErrorMessage}, which strips anything
229
+ * that looks like a credential-bearing URL.
230
+ */
231
+ var RUNTIME_DATABASE_TYPES = [
232
+ "sqlite",
233
+ "postgres",
234
+ "duckdb"
235
+ ];
236
+ function isRuntimeDatabaseType(value) {
237
+ return RUNTIME_DATABASE_TYPES.includes(value);
238
+ }
239
+ /**
240
+ * Sensitive query-parameter names. Matching normalizes the key (lowercase,
241
+ * `_`/`-` stripped), so camelCase (`authToken`, `accessToken`) and hyphen
242
+ * variants (`api-key`) are masked exactly like their snake_case forms.
243
+ */
244
+ var SENSITIVE_QUERY_PARAMS = [
245
+ "access_token",
246
+ "apikey",
247
+ "api_key",
248
+ "auth",
249
+ "auth_token",
250
+ "connectionstring",
251
+ "connection_string",
252
+ "password",
253
+ "token"
254
+ ];
255
+ function normalizeQueryParamName(key) {
256
+ return key.toLowerCase().replace(/[_-]/g, "");
257
+ }
258
+ var SENSITIVE_QUERY_PARAM_NAMES = new Set(SENSITIVE_QUERY_PARAMS.map(normalizeQueryParamName));
259
+ var DEFAULT_CLI_DATABASE = { database: {
260
+ type: "sqlite",
261
+ url: ":memory:"
262
+ } };
263
+ /**
264
+ * Redact a connection string so it can be shown to an agent without leaking
265
+ * credentials. Mirrors the CLI's `redactConnectionString` (which is CLI
266
+ * private); patterned identically so dev-mcp never depends on the CLI.
267
+ *
268
+ * Query-parameter masking normalizes each key (lowercase, `_`/`-` stripped),
269
+ * so camelCase forms such as Turso/libsql's `?authToken=` mask exactly like
270
+ * their snake_case forms. A final regex pass also masks `key=value` pairs
271
+ * embedded in free text (driver error messages often quote the URL); it treats
272
+ * the start of the string, `?`, `&`, `,`, `(`, and whitespace as the
273
+ * preceding boundary.
274
+ */
275
+ function redactConnectionString(value) {
276
+ let redacted = value;
277
+ try {
278
+ const url = new URL(value);
279
+ if (url.password) url.password = "***";
280
+ for (const key of [...url.searchParams.keys()]) if (SENSITIVE_QUERY_PARAM_NAMES.has(normalizeQueryParamName(key))) url.searchParams.set(key, "***");
281
+ redacted = url.toString();
282
+ } catch {
283
+ redacted = value.replace(/([a-z][a-z0-9+.-]*:\/\/[^:\s/@]+:)(?:[^@\s]|@(?=[^@\s]*@))+(@)/gi, "$1***$2");
284
+ }
285
+ redacted = redacted.replace(/(?:[A-Za-z]:)?(?:[\\/][^\s\\/'"`]+)+[\\/]([^\s\\/'"`]+\.(?:db|sqlite3?|duckdb))/g, "…/$1");
286
+ return redacted.replace(/((?:^|[?&,(\s])([a-z][a-z0-9_-]{0,30})=)([^&,\s)]+)/gi, (match, prefix, key) => SENSITIVE_QUERY_PARAM_NAMES.has(normalizeQueryParamName(key)) ? `${prefix}***` : match);
287
+ }
288
+ /**
289
+ * Build a safe, redacted error message for a database failure. Connection
290
+ * strings and raw driver error objects are never surfaced verbatim.
291
+ */
292
+ function safeErrorMessage(error) {
293
+ return redactConnectionString(error instanceof Error ? error.message : String(error ?? "unknown error"));
294
+ }
295
+ /**
296
+ * Normalize a type hint into an engine `getDatabase` accepts. Unknown values
297
+ * throw a safe error (no URL is included) so the caller can surface a
298
+ * diagnostic instead of silently opening the wrong adapter.
299
+ */
300
+ function toRuntimeDatabaseType(value) {
301
+ const normalized = value.trim().toLowerCase();
302
+ if (isRuntimeDatabaseType(normalized)) return normalized;
303
+ throw new Error(`Unsupported runtime database type "${normalized}"; expected sqlite, postgres, or duckdb`);
304
+ }
305
+ /** Infer an engine hint from a URL scheme when no explicit type is given. */
306
+ function inferDatabaseType(url, hint) {
307
+ if (hint && hint.trim().length > 0) return hint;
308
+ if (/^postgres(ql)?:/i.test(url)) return "postgres";
309
+ if (/^duckdb:/i.test(url)) return "duckdb";
310
+ return "sqlite";
311
+ }
312
+ /**
313
+ * Resolve the dev-database connection for one tool call.
314
+ *
315
+ * Returns `db: null` (never throws) when no connection is configured; callers
316
+ * must treat that as "no runtime database" and return a static-only envelope.
317
+ * A thrown connect error is propagated to the caller, which converts it into
318
+ * a diagnostic envelope — it must never reach the MCP transport.
319
+ */
320
+ async function resolveRuntimeConnection(args = {}) {
321
+ const argUrl = args.dbUrl?.trim();
322
+ if (argUrl && argUrl !== ":memory:") {
323
+ const databaseType = toRuntimeDatabaseType(inferDatabaseType(argUrl, args.dbType));
324
+ return {
325
+ db: await getDatabaseInstance({
326
+ type: databaseType,
327
+ url: argUrl
328
+ }),
329
+ source: "argument",
330
+ displayUrl: redactConnectionString(argUrl),
331
+ databaseType
332
+ };
333
+ }
334
+ const envUrl = process.env.SMRT_DEV_DB_URL?.trim();
335
+ if (envUrl && envUrl !== ":memory:") {
336
+ const databaseType = toRuntimeDatabaseType(inferDatabaseType(envUrl, args.dbType));
337
+ return {
338
+ db: await getDatabaseInstance({
339
+ type: databaseType,
340
+ url: envUrl
341
+ }),
342
+ source: "environment",
343
+ displayUrl: redactConnectionString(envUrl),
344
+ databaseType
345
+ };
346
+ }
347
+ const config = await loadCliDatabaseConfig();
348
+ const configUrl = config?.database?.url?.trim();
349
+ if (configUrl && configUrl !== ":memory:") {
350
+ const databaseType = toRuntimeDatabaseType(config.database?.type || inferDatabaseType(configUrl, args.dbType));
351
+ return {
352
+ db: await getDatabaseInstance({
353
+ type: databaseType,
354
+ url: configUrl
355
+ }),
356
+ source: "config",
357
+ displayUrl: redactConnectionString(configUrl),
358
+ databaseType
359
+ };
360
+ }
361
+ return {
362
+ db: null,
363
+ source: "none",
364
+ displayUrl: "",
365
+ databaseType: null
366
+ };
367
+ }
368
+ async function loadCliDatabaseConfig() {
369
+ try {
370
+ await loadConfig();
371
+ const database = getPackageConfig("cli", DEFAULT_CLI_DATABASE).database;
372
+ if (database && typeof database.url === "string") return { database };
373
+ return {};
374
+ } catch {
375
+ return {};
376
+ }
377
+ }
378
+ async function getDatabaseInstance(options) {
379
+ return getDatabase(options);
380
+ }
381
+ /**
382
+ * Best-effort close of a resolved connection. Never throws; diagnostics must
383
+ * not fail because cleanup hiccuped.
384
+ */
385
+ async function closeRuntimeConnection(db) {
386
+ if (!db || typeof db !== "object") return;
387
+ const closeable = db;
388
+ const close = closeable.close ?? closeable.client?.end ?? closeable.client?.close;
389
+ if (typeof close !== "function") return;
390
+ try {
391
+ await close.call(closeable.close ? closeable : closeable.client);
392
+ } catch {}
393
+ }
394
+ //#endregion
395
+ //#region src/tools/runtime/tools.ts
396
+ /**
397
+ * Runtime diagnostics tools (#1824): read-only views over a project's dev
398
+ * database `_smrt_*` system tables, powered by the shared SELECT-only
399
+ * system-diagnostics reader in `@happyvertical/smrt-core`.
400
+ *
401
+ * Contract:
402
+ * - **Optional connection.** No configured connection returns a successful
403
+ * static-only envelope — the server always starts and static tools are
404
+ * unaffected. A live connection is opened lazily per call and closed in
405
+ * `finally`; nothing is cached across calls.
406
+ * - **Read-only.** Every underlying statement is a bounded SELECT; the reader
407
+ * never selects sensitive columns (job payloads/results, schedule
408
+ * `agentConfig`/`methodArgs`, dispatch `payload`/`metadata`).
409
+ * - **Provenance-labeled.** Live results carry `provenance: 'runtime (live DB)'`;
410
+ * static-only results carry `provenance: 'static'` — agents must never
411
+ * conflate runtime facts with declared/manifest facts.
412
+ * - **Fail-safe.** A connect/read error becomes a diagnostic envelope; it must
413
+ * never reach the MCP transport and never includes raw driver text or URLs.
414
+ */
415
+ /** Provenance labels separating runtime facts from static/declared facts. */
416
+ var RUNTIME_PROVENANCE = "runtime (live DB)";
417
+ var STATIC_PROVENANCE = "static";
418
+ /**
419
+ * Serialize resolve → read → close per connection target so overlapping tool
420
+ * calls never close a shared cached handle out from under each other.
421
+ *
422
+ * `@happyvertical/sql`'s `getDatabase` returns a cached handle per URL (no
423
+ * opt-out in its public API). `closeRuntimeConnection` only calls the handle's
424
+ * own `close`/`end`, but the SDK wraps those so a close also evicts the handle
425
+ * from its connection cache. Two concurrent diagnostics calls resolving the
426
+ * same URL would therefore share one handle, with the first finisher closing
427
+ * it mid-read for the second. A per-key promise chain keeps each call's
428
+ * lifecycle private: every call resolves its own view of the connection,
429
+ * performs its read, and only then closes — the next queued call re-resolves
430
+ * a fresh handle.
431
+ *
432
+ * The key is a digest of the resolved target, never the raw URL, so a
433
+ * credential-bearing connection string is not retained in this map.
434
+ */
435
+ var runtimeReadQueues = /* @__PURE__ */ new Map();
436
+ function connectionQueueKey(args) {
437
+ const target = args.dbUrl?.trim() || process.env.SMRT_DEV_DB_URL?.trim() || "cli.config";
438
+ return createHash("sha256").update(target).digest("hex");
439
+ }
440
+ async function enqueueRuntimeRead(key, operation) {
441
+ const run = (runtimeReadQueues.get(key) ?? Promise.resolve()).then(operation, operation);
442
+ const tail = run.catch(() => void 0);
443
+ runtimeReadQueues.set(key, tail);
444
+ tail.then(() => {
445
+ if (runtimeReadQueues.get(key) === tail) runtimeReadQueues.delete(key);
446
+ });
447
+ return run;
448
+ }
449
+ /**
450
+ * Run one read against the optional runtime connection, mapping every outcome
451
+ * to a successful MCP envelope:
452
+ *
453
+ * - no connection configured → static-only envelope (`connected: false`)
454
+ * - connect failure → static envelope with a safe diagnostic
455
+ * - read failure → connected envelope with a safe diagnostic
456
+ * - success → live result under `provenance: 'runtime (live DB)'`; a
457
+ * category-unavailable reader result keeps its `available: false` data and
458
+ * surfaces its message as a diagnostic
459
+ */
460
+ async function withRuntimeConnection(args, read, staticHint) {
461
+ return enqueueRuntimeRead(connectionQueueKey(args), () => runWithRuntimeConnection(args, read, staticHint));
462
+ }
463
+ async function runWithRuntimeConnection(args, read, staticHint) {
464
+ let resolved;
465
+ try {
466
+ resolved = await resolveRuntimeConnection(args);
467
+ } catch (error) {
468
+ return {
469
+ ok: true,
470
+ coverage: null,
471
+ diagnostics: [{
472
+ severity: "warning",
473
+ code: "runtime_connection_error",
474
+ message: safeErrorMessage(error)
475
+ }],
476
+ data: {
477
+ provenance: STATIC_PROVENANCE,
478
+ connected: false
479
+ }
480
+ };
481
+ }
482
+ if (!resolved.db) return {
483
+ ok: true,
484
+ coverage: null,
485
+ diagnostics: [{
486
+ severity: "info",
487
+ code: "runtime_connection_unavailable",
488
+ message: `No runtime dev database configured (set SMRT_DEV_DB_URL or cli.database); returning static-only result: ${staticHint}. Static tools are unaffected.`
489
+ }],
490
+ data: {
491
+ provenance: STATIC_PROVENANCE,
492
+ connected: false
493
+ }
494
+ };
495
+ const { db, source, displayUrl, databaseType } = resolved;
496
+ try {
497
+ const { data, diagnostics } = await read(db);
498
+ return {
499
+ ok: true,
500
+ coverage: null,
501
+ diagnostics,
502
+ data: {
503
+ provenance: RUNTIME_PROVENANCE,
504
+ connected: true,
505
+ connectionSource: source,
506
+ databaseType,
507
+ displayUrl,
508
+ ...data
509
+ }
510
+ };
511
+ } catch (error) {
512
+ return {
513
+ ok: true,
514
+ coverage: null,
515
+ diagnostics: [{
516
+ severity: "warning",
517
+ code: "runtime_read_error",
518
+ message: safeErrorMessage(error)
519
+ }],
520
+ data: {
521
+ provenance: RUNTIME_PROVENANCE,
522
+ connected: true,
523
+ connectionSource: source,
524
+ databaseType,
525
+ displayUrl
526
+ }
527
+ };
528
+ } finally {
529
+ await closeRuntimeConnection(db);
530
+ }
531
+ }
532
+ /**
533
+ * Stored error columns (`error_message`, `last_error`) are free text written
534
+ * at failure time and routinely quote connection URLs or credentials. Every
535
+ * string in a live result passes through {@link redactConnectionString}
536
+ * before it reaches an MCP client; structure and non-string values are kept.
537
+ */
538
+ function redactStrings(value) {
539
+ if (typeof value === "string") return redactConnectionString(value);
540
+ if (Array.isArray(value)) return value.map((item) => redactStrings(item));
541
+ if (value !== null && typeof value === "object") {
542
+ const out = {};
543
+ for (const [key, item] of Object.entries(value)) out[key] = redactStrings(item);
544
+ return out;
545
+ }
546
+ return value;
547
+ }
548
+ /** Convert a reader result into envelope data + diagnostics. */
549
+ function toEnvelopeParts(rawResult) {
550
+ const result = redactStrings(rawResult);
551
+ if (result !== null && typeof result === "object" && "available" in result && result.available === false) {
552
+ const unavailable = result;
553
+ const { message, ...rest } = unavailable;
554
+ return {
555
+ data: rest,
556
+ diagnostics: [{
557
+ severity: unavailable.reason === "retired" ? "info" : "warning",
558
+ code: `category_unavailable_${String(unavailable.reason).replace(/-/g, "_")}`,
559
+ message: String(message)
560
+ }]
561
+ };
562
+ }
563
+ return {
564
+ data: result,
565
+ diagnostics: []
566
+ };
567
+ }
568
+ function readToParts(read) {
569
+ return read.then((result) => toEnvelopeParts(result));
570
+ }
571
+ async function runtimeMigrationStatus(args = {}) {
572
+ const { limit, ...connectionArgs } = args;
573
+ return withRuntimeConnection(connectionArgs, (db) => readToParts(readMigrationStatus(db, { limit })), "no migration status — the manifest still reports the declared schema");
574
+ }
575
+ async function runtimeJobHealth(args = {}) {
576
+ const { limit, ...connectionArgs } = args;
577
+ return withRuntimeConnection(connectionArgs, (db) => readToParts(readJobHealth(db, { limit })), "no job health snapshot — the manifest still reports declared job queues");
578
+ }
579
+ async function runtimeScheduleHealth(args = {}) {
580
+ const { limit, ...connectionArgs } = args;
581
+ return withRuntimeConnection(connectionArgs, (db) => readToParts(readScheduleHealth(db, { limit })), "no schedule health snapshot — the manifest still reports declared schedules");
582
+ }
583
+ async function runtimeDispatchHealth(args = {}) {
584
+ const { limit, ...connectionArgs } = args;
585
+ return withRuntimeConnection(connectionArgs, (db) => readToParts(readDispatchHealth(db, { limit })), "no dispatch health snapshot — the manifest still reports declared dispatch topology");
586
+ }
587
+ async function runtimeRecentChanges(args = {}) {
588
+ const { since, tables, tenantId, limit, ...connectionArgs } = args;
589
+ return withRuntimeConnection(connectionArgs, (db) => readToParts(readRecentChanges(db, {
590
+ since,
591
+ tables,
592
+ tenantId,
593
+ limit
594
+ })), "no recent changes — static knowledge artifacts are unchanged");
595
+ }
596
+ async function runtimeRegistryDrift(args = {}) {
597
+ return withRuntimeConnection(args, (db) => readToParts(readRegistryDrift(db)), "no registry drift report — _smrt_registry is retired; declared objects come from the manifest");
598
+ }
599
+ //#endregion
600
+ //#region src/tools/runtime/observation.ts
601
+ /**
602
+ * Level 2 read-only observation tools over the booted runtime (#1831).
603
+ *
604
+ * Three facts planes, labelled separately in every envelope:
605
+ * - `declared (manifest)`: what the confined boot registered ({@link bootRuntime});
606
+ * - `booted (registry)`: the in-process `ObjectRegistry` projected through the
607
+ * sanitized {@link snapshotRegistry} DTO;
608
+ * - `runtime (live DB)`: the optional read-only connection, reused from Level 1.
609
+ *
610
+ * Nothing here mutates: no writes, no `do()`, no generated CRUD, no project
611
+ * code execution. `runtime-schema-diff` only *introspects* the live schema.
612
+ */
613
+ /** Row budget for `runtime-schema-diff` change lists. */
614
+ var SCHEMA_DIFF_CHANGE_LIMIT = 200;
615
+ function bootDiagnostics(boot) {
616
+ return boot.diagnostics.filter((d) => d.severity !== "info").map((d) => ({
617
+ severity: d.severity === "error" ? "warning" : d.severity,
618
+ code: `boot_${d.code}`,
619
+ message: d.message
620
+ }));
621
+ }
622
+ function bootSummary(boot) {
623
+ return {
624
+ provenance: boot.provenance,
625
+ bootedAt: boot.bootedAt,
626
+ projectName: boot.projectName,
627
+ manifests: boot.manifests,
628
+ objectCount: boot.objectCount
629
+ };
630
+ }
631
+ /** `runtime-registry`: sanitized snapshot of the booted registry. */
632
+ async function runtimeRegistry(args = {}) {
633
+ const boot = await bootRuntime({ projectRoot: args.projectPath });
634
+ const snapshot = snapshotRegistry({
635
+ projectRoot: getBootedProjectRoot() ?? void 0,
636
+ objects: args.objects,
637
+ detail: args.detail ?? Boolean(args.objects?.length)
638
+ });
639
+ return {
640
+ ok: true,
641
+ coverage: null,
642
+ diagnostics: bootDiagnostics(boot),
643
+ data: {
644
+ provenance: snapshot.provenance,
645
+ boot: bootSummary(boot),
646
+ snapshot
647
+ }
648
+ };
649
+ }
650
+ /** `runtime-object`: one object's sanitized definition plus its generated DDL. */
651
+ async function runtimeObject(args) {
652
+ const boot = await bootRuntime({ projectRoot: args.projectPath });
653
+ const name = typeof args.name === "string" ? args.name.trim() : "";
654
+ const snapshot = snapshotRegistry({
655
+ projectRoot: getBootedProjectRoot() ?? void 0,
656
+ objects: name ? [name] : [],
657
+ detail: true
658
+ });
659
+ const diagnostics = bootDiagnostics(boot);
660
+ let object = snapshot.objects[0] ?? null;
661
+ if (snapshot.objects.length > 1) {
662
+ object = null;
663
+ diagnostics.push({
664
+ severity: "warning",
665
+ code: "object_ambiguous",
666
+ message: `${name} is registered by several packages; pass a qualified name: ${snapshot.objects.map((candidate) => candidate.qualifiedName ?? candidate.name).join(", ")}`
667
+ });
668
+ } else if (!object) diagnostics.push({
669
+ severity: "warning",
670
+ code: "object_not_found",
671
+ message: name ? `No booted object named ${name}; use runtime-registry to list names.` : "name is required."
672
+ });
673
+ let ddl = null;
674
+ if (object) try {
675
+ ddl = ObjectRegistry.getSchemaDDL(object.qualifiedName ?? object.name, args.engine) ?? null;
676
+ } catch (error) {
677
+ diagnostics.push({
678
+ severity: "warning",
679
+ code: "ddl_unavailable",
680
+ message: `Generated DDL unavailable: ${error instanceof Error ? error.message : "unknown error"}`
681
+ });
682
+ }
683
+ return {
684
+ ok: true,
685
+ coverage: null,
686
+ diagnostics,
687
+ data: {
688
+ provenance: snapshot.provenance,
689
+ boot: bootSummary(boot),
690
+ object,
691
+ ddl
692
+ }
693
+ };
694
+ }
695
+ /**
696
+ * `runtime-schema-diff`: booted registry schemas versus the live database,
697
+ * using the same comparer `db:diff`/`db:migrate` use. Introspection only —
698
+ * drop/relax options are pinned off and nothing is executed.
699
+ */
700
+ async function runtimeSchemaDiff(args = {}) {
701
+ const boot = await bootRuntime({ projectRoot: args.projectPath });
702
+ const envelope = await withRuntimeConnection(args, async (db) => {
703
+ const diff = await new SchemaComparer(db, {
704
+ includeDroppedTables: false,
705
+ includeDroppedColumns: false,
706
+ includeDroppedIndexes: false,
707
+ relaxColumns: false
708
+ }).compare(ObjectRegistry.getAllSchemasAsDefinitions());
709
+ const byType = {};
710
+ for (const change of diff.changes) {
711
+ const type = String(change.type ?? "unknown");
712
+ byType[type] = (byType[type] ?? 0) + 1;
713
+ }
714
+ return {
715
+ data: {
716
+ boot: bootSummary(boot),
717
+ hasChanges: diff.has_changes,
718
+ addedTables: diff.added_tables.map((t) => t.tableName),
719
+ droppedTables: diff.dropped_tables,
720
+ orphanTables: diff.orphan_tables ?? [],
721
+ changeCount: diff.changes.length,
722
+ changesByType: byType,
723
+ changes: diff.changes.slice(0, SCHEMA_DIFF_CHANGE_LIMIT),
724
+ truncated: diff.changes.length > SCHEMA_DIFF_CHANGE_LIMIT
725
+ },
726
+ diagnostics: []
727
+ };
728
+ }, "booted registry schemas only; connect a dev database to diff against live tables");
729
+ envelope.diagnostics = [...bootDiagnostics(boot), ...envelope.diagnostics];
730
+ return envelope;
731
+ }
732
+ //#endregion
733
+ //#region src/http.ts
734
+ /**
735
+ * Standalone Level 2 runtime dev-plane host (#1831).
736
+ *
737
+ * Serves a *positive* read-only tool catalog over the stateless 2026-07-28
738
+ * Streamable HTTP transport (#2147): `createMcpHandler` with `legacy: 'reject'`
739
+ * and `maxSubscriptions: 0`, adapted to Node with `toNodeHandler`. No SSE,
740
+ * no session header, no sticky routing.
741
+ *
742
+ * Security boundary (development only):
743
+ * - binds loopback only; SDK localhost Host/Origin validation runs first;
744
+ * - every request needs `Authorization: Bearer <token>` (constant-time
745
+ * compare) — from `SMRT_DEV_MCP_TOKEN` or minted per process;
746
+ * - no authenticated principal exists here, so scope stays fail-closed
747
+ * global-only exactly as in Level 1;
748
+ * - the catalog never includes generated CRUD, custom actions, `do()`, or
749
+ * tool-backed `is()`.
750
+ */
751
+ /**
752
+ * The complete Level 2 catalog. A tool is exposed over HTTP only if it is
753
+ * named here; the static stdio catalog is deliberately not mounted.
754
+ */
755
+ var RUNTIME_HTTP_TOOL_NAMES = [
756
+ "runtime-registry",
757
+ "runtime-object",
758
+ "runtime-schema-diff",
759
+ "migration-status",
760
+ "job-health",
761
+ "schedule-health",
762
+ "dispatch-health",
763
+ "recent-changes",
764
+ "registry-drift"
765
+ ];
766
+ var RUNTIME_HTTP_HANDLERS = {
767
+ "runtime-registry": (args) => runtimeRegistry(args),
768
+ "runtime-object": (args) => runtimeObject(args),
769
+ "runtime-schema-diff": (args) => runtimeSchemaDiff(args),
770
+ "migration-status": (args) => runtimeMigrationStatus(args),
771
+ "job-health": (args) => runtimeJobHealth(args),
772
+ "schedule-health": (args) => runtimeScheduleHealth(args),
773
+ "dispatch-health": (args) => runtimeDispatchHealth(args),
774
+ "recent-changes": (args) => runtimeRecentChanges(args),
775
+ "registry-drift": (args) => runtimeRegistryDrift(args)
776
+ };
777
+ /** Catalog definitions for the HTTP plane, in catalog order. */
778
+ function runtimeHttpTools() {
779
+ const names = new Set(RUNTIME_HTTP_TOOL_NAMES);
780
+ return TOOLS.filter((tool) => names.has(tool.name));
781
+ }
782
+ /**
783
+ * Build a fresh protocol server per request (stateless transport contract).
784
+ * `projectRoot` is fixed at host start; per-request `projectPath` arguments
785
+ * are ignored so a client cannot re-point the booted process.
786
+ */
787
+ function createRuntimeProtocolServer(projectRoot) {
788
+ const server = new Server({
789
+ name: `${SERVER_NAME}-runtime`,
790
+ version: SERVER_VERSION
791
+ }, { capabilities: { tools: {} } });
792
+ server.setRequestHandler("tools/list", async () => ({ tools: runtimeHttpTools() }));
793
+ server.setRequestHandler("tools/call", async (request) => {
794
+ const name = request.params.name;
795
+ const handler = RUNTIME_HTTP_HANDLERS[name];
796
+ if (typeof handler !== "function") throw new Error(`Unknown runtime tool: ${name}`);
797
+ const { projectPath: _ignored, ...args } = request.params.arguments ?? {};
798
+ const result = await handler({
799
+ ...args,
800
+ projectPath: projectRoot
801
+ });
802
+ return {
803
+ content: [{
804
+ type: "text",
805
+ text: JSON.stringify(result, null, 2)
806
+ }],
807
+ structuredContent: result
808
+ };
809
+ });
810
+ return server;
811
+ }
812
+ var LOOPBACK_HOSTS = /* @__PURE__ */ new Set([
813
+ "127.0.0.1",
814
+ "localhost",
815
+ "::1"
816
+ ]);
817
+ function bearerMatches(header, token) {
818
+ if (!header?.startsWith("Bearer ")) return false;
819
+ const presented = Buffer.from(header.slice(7));
820
+ const expected = Buffer.from(token);
821
+ return presented.length === expected.length && timingSafeEqual(presented, expected);
822
+ }
823
+ function deny(res, status, message) {
824
+ res.statusCode = status;
825
+ res.setHeader("content-type", "application/json");
826
+ if (status === 401) res.setHeader("www-authenticate", "Bearer realm=\"smrt-dev-mcp runtime\"");
827
+ res.end(JSON.stringify({ error: message }));
828
+ }
829
+ /** Start the Level 2 host. Boots the confined runtime before listening. */
830
+ async function startRuntimeHttpHost(options = {}) {
831
+ const host = options.host ?? "127.0.0.1";
832
+ if (!LOOPBACK_HOSTS.has(host)) throw new Error("The runtime dev-plane binds loopback only (127.0.0.1, localhost, ::1).");
833
+ const path = options.path ?? "/mcp";
834
+ const token = options.token ?? process.env.SMRT_DEV_MCP_TOKEN?.trim() ?? randomBytes(24).toString("base64url");
835
+ if (!token) throw new Error("SMRT_DEV_MCP_TOKEN must not be empty.");
836
+ const projectRoot = options.projectRoot ?? process.cwd();
837
+ const boot = await bootRuntime({ projectRoot });
838
+ const mcp = toNodeHandler(createMcpHandler(() => createRuntimeProtocolServer(projectRoot), {
839
+ legacy: "reject",
840
+ maxSubscriptions: 0
841
+ }));
842
+ const hostGuard = localhostHostValidation();
843
+ const originGuard = localhostOriginValidation();
844
+ const httpServer = createServer$1((req, res) => {
845
+ if (!hostGuard(req, res)) return;
846
+ if (!originGuard(req, res)) return;
847
+ if (new URL(req.url ?? "/", "http://localhost").pathname !== path) {
848
+ deny(res, 404, "not found");
849
+ return;
850
+ }
851
+ if (!bearerMatches(req.headers.authorization, token)) {
852
+ deny(res, 401, "missing or invalid bearer token");
853
+ return;
854
+ }
855
+ mcp(req, res);
856
+ });
857
+ await new Promise((resolve, reject) => {
858
+ httpServer.once("error", reject);
859
+ httpServer.listen(options.port ?? 0, host, () => {
860
+ httpServer.off("error", reject);
861
+ resolve();
862
+ });
863
+ });
864
+ const address = httpServer.address();
865
+ return {
866
+ url: `http://${address.family === "IPv6" ? `[${address.address}]` : address.address}:${address.port}${path}`,
867
+ token,
868
+ boot,
869
+ close: () => new Promise((resolve, reject) => {
870
+ httpServer.closeAllConnections?.();
871
+ httpServer.close((error) => error ? reject(error) : resolve());
872
+ })
873
+ };
874
+ }
875
+ //#endregion
46
876
  //#region src/tools/generate-smrt-class.ts
47
877
  var TYPE_MAPPING = {
48
878
  text: {
@@ -1058,8 +1888,6 @@ function pathExistsSyncHint(path) {
1058
1888
  * Provides code generation, project introspection, knowledge context,
1059
1889
  * review/architecture prompt bundles, and portable agent skills.
1060
1890
  */
1061
- var SERVER_NAME = "smrt-dev-mcp";
1062
- var SERVER_VERSION = readPackageVersion();
1063
1891
  var DEBUG = process.env.DEBUG === "true";
1064
1892
  var REVIEW_SKILL_URI = `smrt-dev-mcp://agent-skills/${REVIEW_SKILL_NAME}`;
1065
1893
  var DOMAIN_CODE_REVIEW_PROMPT = "domain-code-review";
@@ -1329,7 +2157,7 @@ function createServer() {
1329
2157
  const { name, arguments: args } = request.params;
1330
2158
  if (DEBUG) {
1331
2159
  console.error(`[${SERVER_NAME}] CallTool: ${name}`);
1332
- console.error(`[${SERVER_NAME}] Arguments:`, JSON.stringify(args, null, 2));
2160
+ console.error(`[${SERVER_NAME}] Arguments:`, JSON.stringify(redactDebugArguments(args), null, 2));
1333
2161
  }
1334
2162
  try {
1335
2163
  let result;
@@ -1404,6 +2232,33 @@ function createServer() {
1404
2232
  case "get-agent-skill":
1405
2233
  result = JSON.stringify(await getAgentSkill(args), null, 2);
1406
2234
  break;
2235
+ case "migration-status":
2236
+ result = JSON.stringify(await runtimeMigrationStatus(args), null, 2);
2237
+ break;
2238
+ case "job-health":
2239
+ result = JSON.stringify(await runtimeJobHealth(args), null, 2);
2240
+ break;
2241
+ case "schedule-health":
2242
+ result = JSON.stringify(await runtimeScheduleHealth(args), null, 2);
2243
+ break;
2244
+ case "dispatch-health":
2245
+ result = JSON.stringify(await runtimeDispatchHealth(args), null, 2);
2246
+ break;
2247
+ case "recent-changes":
2248
+ result = JSON.stringify(await runtimeRecentChanges(args), null, 2);
2249
+ break;
2250
+ case "registry-drift":
2251
+ result = JSON.stringify(await runtimeRegistryDrift(args), null, 2);
2252
+ break;
2253
+ case "runtime-registry":
2254
+ result = JSON.stringify(await runtimeRegistry(args), null, 2);
2255
+ break;
2256
+ case "runtime-object":
2257
+ result = JSON.stringify(await runtimeObject(args), null, 2);
2258
+ break;
2259
+ case "runtime-schema-diff":
2260
+ result = JSON.stringify(await runtimeSchemaDiff(args), null, 2);
2261
+ break;
1407
2262
  default: throw new Error(`Unknown tool: ${name}`);
1408
2263
  }
1409
2264
  return {
@@ -1459,15 +2314,6 @@ function isEntrypoint() {
1459
2314
  return import.meta.url === pathToFileURL(entry).href;
1460
2315
  }
1461
2316
  }
1462
- function readPackageVersion() {
1463
- try {
1464
- const packageJsonPath = join(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
1465
- const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
1466
- return typeof packageJson.version === "string" ? packageJson.version : "0.0.0";
1467
- } catch {
1468
- return "0.0.0";
1469
- }
1470
- }
1471
2317
  function renderAgentSkillMarkdown(name) {
1472
2318
  const skill = getAgentSkill({
1473
2319
  name,
@@ -1522,11 +2368,26 @@ function detailArg(args) {
1522
2368
  const detail = args?.detail;
1523
2369
  return typeof detail === "string" ? detail : void 0;
1524
2370
  }
2371
+ /**
2372
+ * Debug logging happens before any tool runs its own redaction, so a
2373
+ * credential-bearing `dbUrl` argument must be masked here.
2374
+ */
2375
+ function redactDebugArguments(args) {
2376
+ if (!isRecord(args) || typeof args.dbUrl !== "string") return args;
2377
+ return {
2378
+ ...args,
2379
+ dbUrl: redactConnectionString(args.dbUrl)
2380
+ };
2381
+ }
2382
+ function isRuntimeEnvelope(value) {
2383
+ return isRecord(value) && typeof value.ok === "boolean" && "coverage" in value && Array.isArray(value.diagnostics) && "data" in value;
2384
+ }
1525
2385
  function toDevToolStructuredContent(result) {
1526
2386
  let data = result;
1527
2387
  try {
1528
2388
  data = JSON.parse(result);
1529
2389
  } catch {}
2390
+ if (isRuntimeEnvelope(data)) return data;
1530
2391
  const source = isRecord(data) ? data : void 0;
1531
2392
  return {
1532
2393
  ok: true,
@@ -1569,11 +2430,53 @@ function sanitizePath(path) {
1569
2430
  if (path.startsWith("/") || /^[A-Za-z]:[\\/]/.test(path)) return "<absolute-path>";
1570
2431
  return path;
1571
2432
  }
1572
- if (isEntrypoint()) main().catch((error) => {
1573
- console.error(`[${SERVER_NAME}] Fatal error:`, error);
1574
- process.exit(1);
1575
- });
2433
+ /**
2434
+ * `smrt-dev-mcp --http [--port N] [--project DIR]` starts the Level 2 runtime
2435
+ * dev-plane host (#1831) instead of the stdio server. Loopback-only, bearer
2436
+ * protected, positive read-only catalog; see `http.ts`.
2437
+ */
2438
+ function parseHttpCliArgs(argv) {
2439
+ const result = { http: false };
2440
+ for (let index = 0; index < argv.length; index += 1) {
2441
+ const arg = argv[index];
2442
+ if (arg === "--http") result.http = true;
2443
+ else if (arg === "--port") {
2444
+ const value = Number.parseInt(argv[index + 1] ?? "", 10);
2445
+ if (!Number.isInteger(value) || value < 0 || value > 65535) throw new Error("--port requires an integer between 0 and 65535");
2446
+ result.port = value;
2447
+ index += 1;
2448
+ } else if (arg === "--project") {
2449
+ const value = argv[index + 1];
2450
+ if (!value) throw new Error("--project requires a directory");
2451
+ result.projectRoot = value;
2452
+ index += 1;
2453
+ }
2454
+ }
2455
+ return result;
2456
+ }
2457
+ async function mainHttp(cli) {
2458
+ const host = await startRuntimeHttpHost({
2459
+ port: cli.port,
2460
+ projectRoot: cli.projectRoot
2461
+ });
2462
+ const minted = !process.env.SMRT_DEV_MCP_TOKEN?.trim();
2463
+ console.error(`[${SERVER_NAME}] runtime dev-plane listening at ${host.url} (${host.boot.objectCount} booted objects, ${host.boot.manifests.length} manifests)`);
2464
+ if (minted) console.error(`[${SERVER_NAME}] bearer token: ${host.token}`);
2465
+ const shutdown = async () => {
2466
+ await host.close();
2467
+ process.exit(0);
2468
+ };
2469
+ process.on("SIGINT", shutdown);
2470
+ process.on("SIGTERM", shutdown);
2471
+ }
2472
+ if (isEntrypoint()) {
2473
+ const cli = parseHttpCliArgs(process.argv.slice(2));
2474
+ (cli.http ? mainHttp(cli) : main()).catch((error) => {
2475
+ console.error(`[${SERVER_NAME}] Fatal error:`, error);
2476
+ process.exit(1);
2477
+ });
2478
+ }
1576
2479
  //#endregion
1577
- export { SERVER_VERSION, TOOLS, createServer };
2480
+ export { SERVER_VERSION, TOOLS, createServer, parseHttpCliArgs };
1578
2481
 
1579
2482
  //# sourceMappingURL=index.js.map