velocious 1.0.692 → 1.0.694

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/README.md +5 -0
  2. package/build/background-jobs/scheduled-enqueue-bound.js +18 -0
  3. package/build/background-jobs/scheduled-enqueue-timeout-error.js +25 -0
  4. package/build/background-jobs/scheduler.js +121 -4
  5. package/build/cli/commands/mcp.js +7 -0
  6. package/build/cli/index.js +1 -1
  7. package/build/configuration-types.js +9 -0
  8. package/build/configuration.js +77 -2
  9. package/build/database/advisory-lock-bound.js +16 -0
  10. package/build/database/drivers/base.js +23 -2
  11. package/build/database/drivers/pgsql/index.js +27 -4
  12. package/build/environment-handlers/base.js +9 -0
  13. package/build/environment-handlers/node/cli/commands/mcp.js +87 -0
  14. package/build/environment-handlers/node.js +10 -0
  15. package/build/mcp/dispatcher.js +271 -0
  16. package/build/mcp/json-rpc-server.js +167 -0
  17. package/build/mcp/manifest-tools.js +310 -0
  18. package/build/routes/built-in/mcp/controller.js +63 -0
  19. package/build/routes/catch-all-route.js +58 -0
  20. package/build/routes/resolver.js +21 -8
  21. package/build/routes/root-route.js +26 -1
  22. package/build/src/background-jobs/scheduled-enqueue-bound.d.ts +15 -0
  23. package/build/src/background-jobs/scheduled-enqueue-bound.d.ts.map +1 -0
  24. package/build/src/background-jobs/scheduled-enqueue-bound.js +17 -0
  25. package/build/src/background-jobs/scheduled-enqueue-timeout-error.d.ts +24 -0
  26. package/build/src/background-jobs/scheduled-enqueue-timeout-error.d.ts.map +1 -0
  27. package/build/src/background-jobs/scheduled-enqueue-timeout-error.js +23 -0
  28. package/build/src/background-jobs/scheduler.d.ts +40 -0
  29. package/build/src/background-jobs/scheduler.d.ts.map +1 -1
  30. package/build/src/background-jobs/scheduler.js +111 -5
  31. package/build/src/cli/commands/mcp.d.ts +5 -0
  32. package/build/src/cli/commands/mcp.d.ts.map +1 -0
  33. package/build/src/cli/commands/mcp.js +7 -0
  34. package/build/src/cli/index.js +2 -2
  35. package/build/src/configuration-types.d.ts +30 -0
  36. package/build/src/configuration-types.d.ts.map +1 -1
  37. package/build/src/configuration-types.js +9 -1
  38. package/build/src/configuration.d.ts +24 -1
  39. package/build/src/configuration.d.ts.map +1 -1
  40. package/build/src/configuration.js +69 -3
  41. package/build/src/database/advisory-lock-bound.d.ts +15 -0
  42. package/build/src/database/advisory-lock-bound.d.ts.map +1 -0
  43. package/build/src/database/advisory-lock-bound.js +16 -0
  44. package/build/src/database/drivers/base.d.ts +13 -1
  45. package/build/src/database/drivers/base.d.ts.map +1 -1
  46. package/build/src/database/drivers/base.js +23 -3
  47. package/build/src/database/drivers/pgsql/index.d.ts +15 -3
  48. package/build/src/database/drivers/pgsql/index.d.ts.map +1 -1
  49. package/build/src/database/drivers/pgsql/index.js +23 -5
  50. package/build/src/environment-handlers/base.d.ts +6 -0
  51. package/build/src/environment-handlers/base.d.ts.map +1 -1
  52. package/build/src/environment-handlers/base.js +9 -1
  53. package/build/src/environment-handlers/node/cli/commands/mcp.d.ts +19 -0
  54. package/build/src/environment-handlers/node/cli/commands/mcp.d.ts.map +1 -0
  55. package/build/src/environment-handlers/node/cli/commands/mcp.js +70 -0
  56. package/build/src/environment-handlers/node.d.ts +6 -0
  57. package/build/src/environment-handlers/node.d.ts.map +1 -1
  58. package/build/src/environment-handlers/node.js +10 -1
  59. package/build/src/mcp/dispatcher.d.ts +133 -0
  60. package/build/src/mcp/dispatcher.d.ts.map +1 -0
  61. package/build/src/mcp/dispatcher.js +232 -0
  62. package/build/src/mcp/json-rpc-server.d.ts +47 -0
  63. package/build/src/mcp/json-rpc-server.d.ts.map +1 -0
  64. package/build/src/mcp/json-rpc-server.js +142 -0
  65. package/build/src/mcp/manifest-tools.d.ts +76 -0
  66. package/build/src/mcp/manifest-tools.d.ts.map +1 -0
  67. package/build/src/mcp/manifest-tools.js +273 -0
  68. package/build/src/routes/built-in/mcp/controller.d.ts +14 -0
  69. package/build/src/routes/built-in/mcp/controller.d.ts.map +1 -0
  70. package/build/src/routes/built-in/mcp/controller.js +55 -0
  71. package/build/src/routes/catch-all-route.d.ts +48 -0
  72. package/build/src/routes/catch-all-route.d.ts.map +1 -0
  73. package/build/src/routes/catch-all-route.js +50 -0
  74. package/build/src/routes/resolver.d.ts.map +1 -1
  75. package/build/src/routes/resolver.js +23 -9
  76. package/build/src/routes/root-route.d.ts +15 -1
  77. package/build/src/routes/root-route.d.ts.map +1 -1
  78. package/build/src/routes/root-route.js +24 -2
  79. package/package.json +1 -1
  80. package/src/background-jobs/scheduled-enqueue-bound.js +18 -0
  81. package/src/background-jobs/scheduled-enqueue-timeout-error.js +25 -0
  82. package/src/background-jobs/scheduler.js +121 -4
  83. package/src/cli/commands/mcp.js +7 -0
  84. package/src/cli/index.js +1 -1
  85. package/src/configuration-types.js +9 -0
  86. package/src/configuration.js +77 -2
  87. package/src/database/advisory-lock-bound.js +16 -0
  88. package/src/database/drivers/base.js +23 -2
  89. package/src/database/drivers/pgsql/index.js +27 -4
  90. package/src/environment-handlers/base.js +9 -0
  91. package/src/environment-handlers/node/cli/commands/mcp.js +87 -0
  92. package/src/environment-handlers/node.js +10 -0
  93. package/src/mcp/dispatcher.js +271 -0
  94. package/src/mcp/json-rpc-server.js +167 -0
  95. package/src/mcp/manifest-tools.js +310 -0
  96. package/src/routes/built-in/mcp/controller.js +63 -0
  97. package/src/routes/catch-all-route.js +58 -0
  98. package/src/routes/resolver.js +21 -8
  99. package/src/routes/root-route.js +26 -1
package/README.md CHANGED
@@ -50,6 +50,7 @@
50
50
  * AbortSignal-driven MySQL/MariaDB query cancellation for raw, model, and cross-tenant aggregate queries (see [docs/database-query-cancellation.md](docs/database-query-cancellation.md))
51
51
  * Optional built-in debug endpoint for inspecting server and database connection state (see [docs/debug-endpoint.md](docs/debug-endpoint.md))
52
52
  * Optional built-in API manifest endpoint describing every registered frontend-model resource as human- and machine-readable JSON (see [docs/api-manifest-endpoint.md](docs/api-manifest-endpoint.md))
53
+ * Built-in MCP server command `velocious mcp` exposing every registered frontend-model command as MCP tools for AI clients, dispatched through the frontend-model controller with the app's own ability resolver (see [docs/mcp-server.md](docs/mcp-server.md))
53
54
  * Backend record attachments with filesystem, S3, native callback, bounded Node path-input persistence, and model-declared client sync policy (see [docs/attachments.md](docs/attachments.md))
54
55
 
55
56
  # Setup
@@ -975,6 +976,10 @@ Frontend-model command transport preserves `Date` and `undefined` by encoding th
975
976
 
976
977
  Frontend-model commands raise an `Error` when the backend responds with `{status: "error"}` (using `errorMessage` when present), so unauthorized or missing-record update/find/destroy responses fail fast in frontend code.
977
978
 
979
+ ## Route catch-all
980
+
981
+ Applications can declare their own 404 response in the routes file with `route.catchAll({action: "notFound", controller: "errors"})` instead of hand-wiring a route resolver hook. Unmatched requests then resolve to the given controller/action through the standard controller lookup, without tenant/ability/connection resolution. See [docs/route-catch-all.md](docs/route-catch-all.md).
982
+
978
983
  ## Route resolver hooks
979
984
 
980
985
  Libraries can hook unresolved routes and hijack them before Velocious falls back to the built-in 404 controller.
@@ -0,0 +1,18 @@
1
+ // @ts-check
2
+
3
+ import {DEFAULT_ADVISORY_LOCK_ACQUIRE_TIMEOUT_MS} from "../database/advisory-lock-bound.js"
4
+
5
+ /**
6
+ * Default bound for one scheduled background-job enqueue attempt.
7
+ *
8
+ * A durable enqueue that never settles (wedged DB connection, advisory lock held by a dead process)
9
+ * used to poison its schedule key forever: `BackgroundJobsScheduler` kept the in-flight promise in
10
+ * `pendingEnqueuesByJobKey`, every later tick returned early, and shutdown could never drain. The
11
+ * bound makes that stall observable and self-healing instead of silent.
12
+ *
13
+ * It is deliberately larger than the advisory-lock acquisition bound, because a scheduled enqueue
14
+ * waits for a lock *and then* inserts. Keeping the enqueue watchdog outside the lock bound lets the
15
+ * precise `AdvisoryLockTimeoutError` surface first — naming the lock that is actually stuck —
16
+ * instead of a generic enqueue timeout that hides which lock caused the stall.
17
+ * @type {number} */
18
+ export const DEFAULT_SCHEDULED_ENQUEUE_TIMEOUT_MS = DEFAULT_ADVISORY_LOCK_ACQUIRE_TIMEOUT_MS + 5 * 60 * 1000
@@ -0,0 +1,25 @@
1
+ // @ts-check
2
+
3
+ import {TimeoutError} from "awaitery/build/timeout.js"
4
+
5
+ /** Typed failure for a scheduled background-job enqueue that never settled within its bound. */
6
+ export default class ScheduledBackgroundJobEnqueueTimeoutError extends TimeoutError {
7
+ /**
8
+ * Builds a scheduled-enqueue timeout error without job arguments or connection details.
9
+ * @param {object} args - Safe timeout context.
10
+ * @param {string} args.jobKey - Schedule key whose enqueue stalled.
11
+ * @param {string} args.jobName - Resolved job class name.
12
+ * @param {number} args.scheduledEnqueueTimeoutMs - Configured bound that expired.
13
+ * @param {TimeoutError} [args.cause] - Original watchdog timeout.
14
+ */
15
+ constructor({jobKey, jobName, scheduledEnqueueTimeoutMs, cause}) {
16
+ super(`Scheduled background job enqueue did not settle within ${scheduledEnqueueTimeoutMs}ms`, cause ? {cause} : undefined)
17
+
18
+ this.name = "ScheduledBackgroundJobEnqueueTimeoutError"
19
+ /** @type {"BACKGROUND_JOB_SCHEDULED_ENQUEUE_TIMEOUT"} */
20
+ this.code = "BACKGROUND_JOB_SCHEDULED_ENQUEUE_TIMEOUT"
21
+ this.jobKey = jobKey
22
+ this.jobName = jobName
23
+ this.scheduledEnqueueTimeoutMs = scheduledEnqueueTimeoutMs
24
+ }
25
+ }
@@ -1,5 +1,8 @@
1
1
  // @ts-check
2
2
 
3
+ import {DEFAULT_SCHEDULED_ENQUEUE_TIMEOUT_MS} from "./scheduled-enqueue-bound.js"
4
+ import ScheduledBackgroundJobEnqueueTimeoutError from "./scheduled-enqueue-timeout-error.js"
5
+
3
6
  import Logger from "../logger.js"
4
7
  import {nextCronFireDate, parseCronExpression} from "./cron-expression.js"
5
8
 
@@ -25,6 +28,12 @@ const DURATION_MULTIPLIERS = {
25
28
  weeks: 7 * 24 * 60 * 60 * 1000
26
29
  }
27
30
 
31
+ // The watchdog below is internal machinery, not a schedule timer. Capturing the
32
+ // platform timers at module load keeps them out of a caller's schedule-timer
33
+ // observation, so bounding an enqueue never appears as an extra scheduled job.
34
+ const armWatchdogTimer = globalThis.setTimeout
35
+ const cancelWatchdogTimer = globalThis.clearTimeout
36
+
28
37
  /**
29
38
  * Runs the parseScheduledDuration helper.
30
39
  * @param {number | string} value - Duration value.
@@ -73,6 +82,8 @@ export default class BackgroundJobsScheduler {
73
82
  this.configuration = configuration
74
83
  this.enqueueJob = enqueueJob
75
84
  this.logger = new Logger(this)
85
+ /** @type {number | null} - Bound (ms) for one scheduled enqueue attempt; `null` disables the bound. */
86
+ this.scheduledEnqueueTimeoutMs = this._resolveScheduledEnqueueTimeoutMs()
76
87
  /**
77
88
  * Narrows the runtime value to the documented type.
78
89
  * @type {Array<ReturnType<typeof setInterval>>} */
@@ -270,17 +281,123 @@ export default class BackgroundJobsScheduler {
270
281
  // of identical scheduled jobs when the queue backed up. Dedup is by job identity (see the
271
282
  // store), so the job keeps its queue-derived concurrency cap. A schedule can opt out with
272
283
  // `deduplicateWhileQueued: false`.
273
- await this.enqueueJob({
274
- args: Array.isArray(jobConfiguration.args) ? jobConfiguration.args : [],
275
- jobClass: jobConfiguration.class,
284
+ await this._withScheduledEnqueueTimeout({
276
285
  jobKey,
277
- options: {deduplicateWhileQueued: true, ...(jobConfiguration.options || {})}
286
+ jobName: jobConfiguration.class.jobName(),
287
+ run: async () => {
288
+ await this.enqueueJob({
289
+ args: Array.isArray(jobConfiguration.args) ? jobConfiguration.args : [],
290
+ jobClass: jobConfiguration.class,
291
+ jobKey,
292
+ options: {deduplicateWhileQueued: true, ...(jobConfiguration.options || {})}
293
+ })
294
+ }
278
295
  })
279
296
  } catch (error) {
297
+ if (error instanceof ScheduledBackgroundJobEnqueueTimeoutError) {
298
+ this._reportScheduledEnqueueStall({error, jobKey})
299
+
300
+ return
301
+ }
302
+
280
303
  await this.logger.error(() => ["Failed to enqueue scheduled background job", {jobKey, jobName: jobConfiguration.class.jobName()}, error])
281
304
  }
282
305
  }
283
306
 
307
+ /**
308
+ * Runs one durable enqueue attempt under the scheduled-enqueue bound.
309
+ *
310
+ * The bound is applied with a local race rather than `awaitery`'s `timeout()`:
311
+ * that helper starts its callback through `Promise.resolve().then(...)`, which
312
+ * would defer every scheduled enqueue by a microtask and change the tick
313
+ * semantics callers rely on. Here the attempt starts synchronously, and the
314
+ * watchdog uses the module-captured timers so it is never observable as a
315
+ * schedule timer. Only the watchdog raises the typed timeout error, so a
316
+ * timeout raised inside the enqueue itself keeps its own error path.
317
+ * @param {object} args - Options.
318
+ * @param {string} args.jobKey - Schedule key.
319
+ * @param {string} args.jobName - Resolved job class name.
320
+ * @param {() => Promise<void>} args.run - Enqueue attempt.
321
+ * @returns {Promise<void>} - Resolves when the attempt settles within the bound.
322
+ */
323
+ async _withScheduledEnqueueTimeout({jobKey, jobName, run}) {
324
+ const timeoutMs = this.scheduledEnqueueTimeoutMs
325
+ /** @type {Promise<void>} */
326
+ let attempt
327
+
328
+ try {
329
+ attempt = Promise.resolve(run())
330
+ } catch (error) {
331
+ attempt = Promise.reject(/** @type {Error} */ (error))
332
+ }
333
+
334
+ if (timeoutMs === null) return await attempt
335
+
336
+ /**
337
+ * Rejects the watchdog promise when the bound expires.
338
+ * @type {(error: Error) => void} */
339
+ let rejectScheduledEnqueueTimeout = () => {}
340
+ const watchdog = new Promise((_, reject) => { rejectScheduledEnqueueTimeout = reject })
341
+ /**
342
+ * Watchdog timer handle.
343
+ * @type {ReturnType<typeof armWatchdogTimer>} */
344
+ const watchdogId = armWatchdogTimer(() => {
345
+ rejectScheduledEnqueueTimeout(new ScheduledBackgroundJobEnqueueTimeoutError({
346
+ jobKey,
347
+ jobName,
348
+ scheduledEnqueueTimeoutMs: timeoutMs
349
+ }))
350
+ }, timeoutMs)
351
+
352
+ try {
353
+ return await Promise.race([attempt, watchdog])
354
+ } finally {
355
+ cancelWatchdogTimer(watchdogId)
356
+ }
357
+ }
358
+
359
+ /**
360
+ * Surfaces a scheduled enqueue whose store call never settled. Without this report the only
361
+ * symptom is a schedule that silently stops enqueueing after one wedged call.
362
+ * @param {object} args - Options.
363
+ * @param {Error} args.error - Timeout error.
364
+ * @param {string} args.jobKey - Schedule key.
365
+ * @returns {void}
366
+ */
367
+ _reportScheduledEnqueueStall({error, jobKey}) {
368
+ const payload = {
369
+ context: {
370
+ jobKey,
371
+ scheduledEnqueueTimeoutMs: this.scheduledEnqueueTimeoutMs,
372
+ stage: "background-jobs-scheduled-enqueue-stall"
373
+ },
374
+ error
375
+ }
376
+
377
+ void this.logger.error(() => ["Scheduled background job enqueue stalled", payload.context, error])
378
+
379
+ const errorEvents = typeof this.configuration?.getErrorEvents === "function" ? this.configuration.getErrorEvents() : undefined
380
+
381
+ if (!errorEvents) return
382
+
383
+ errorEvents.emit("framework-error", payload)
384
+ errorEvents.emit("all-error", {...payload, errorType: "framework-error"})
385
+ }
386
+
387
+ /**
388
+ * Resolves the scheduled-enqueue bound from configuration.
389
+ * @returns {number | null} - Bound in milliseconds, or `null` for an unbounded wait.
390
+ */
391
+ _resolveScheduledEnqueueTimeoutMs() {
392
+ const backgroundJobsConfig = typeof this.configuration?.getBackgroundJobsConfig === "function" ? this.configuration.getBackgroundJobsConfig() : undefined
393
+ const configured = backgroundJobsConfig?.scheduledEnqueueTimeoutMs
394
+
395
+ if (configured === null) return null
396
+ if (typeof configured === "number" && configured > 0) return configured
397
+
398
+ return DEFAULT_SCHEDULED_ENQUEUE_TIMEOUT_MS
399
+ }
400
+
284
401
  /**
285
402
  * Runs normalize every.
286
403
  * @param {NonNullable<import("../configuration-types.js").ScheduledBackgroundJobConfiguration["every"]>} every - Every config (caller must guarantee not undefined).
@@ -0,0 +1,7 @@
1
+ import BaseCommand from "../base-command.js"
2
+
3
+ export default class VelociousCliCommandsMcp extends BaseCommand{
4
+ async execute() {
5
+ return await this.getConfiguration().getEnvironmentHandler().cliCommandsMcp(this)
6
+ }
7
+ }
@@ -1,6 +1,6 @@
1
1
  // @ts-check
2
2
 
3
- const COMMAND_VALUE_OPTIONS = new Set(["--generation", "--initial-generation-state", "--lifecycle-socket", "--phase", "--socket", "--tenant"])
3
+ const COMMAND_VALUE_OPTIONS = new Set(["--generation", "--initial-generation-state", "--lifecycle-socket", "--phase", "--request-context", "--socket", "--tenant"])
4
4
 
5
5
  export default class VelociousCli {
6
6
  /**
@@ -68,6 +68,7 @@
68
68
 
69
69
  /**
70
70
  * @typedef {object} DatabaseConfigurationType
71
+ * @property {number | null} [advisoryLockAcquireTimeoutMs] - Bound (ms) a blocking advisory-lock acquisition waits for a contended lock before reporting failure. Default: `900000` (15 minutes). `null` opts out and restores the driver's indefinite wait. A per-call `timeoutMs` argument always overrides this.
71
72
  * @property {string} [databaseCharset] - Default character set applied by `db:create` via mysql/mariadb `CREATE DATABASE ... CHARACTER SET`. Distinct from `charset`, which is the client connection charset forwarded to the mysql2 driver.
72
73
  * @property {string} [databaseCollation] - Default collation applied by `db:create` via mysql/mariadb `CREATE DATABASE ... COLLATE`.
73
74
  * @property {string} [database] - Database name for this connection.
@@ -222,6 +223,7 @@
222
223
  * Priorities may be any number, including negative to sink a queue below the
223
224
  * default. Jobs within the same priority keep FIFO (`scheduled_at`, then
224
225
  * `created_at`) order. Default: `{}` (no queue caps, all queues priority `0`).
226
+ * @property {number | null} [scheduledEnqueueTimeoutMs] - Bounds one scheduled enqueue attempt in the main process. A durable enqueue that never settles (wedged DB connection, contended advisory lock) would otherwise keep its schedule key permanently in flight, so every later tick skipped itself and shutdown could never drain. On timeout the attempt is reported as `background-jobs-scheduled-enqueue-stall` and the key clears for the next tick. Default: 20 minutes — deliberately 5 minutes above `advisoryLockAcquireTimeoutMs` so the precise lock error surfaces first. `null` opts out.
225
227
  * @property {BackgroundJobsDispatchStrategy} [dispatchStrategy] - How the main process
226
228
  * detects new work. Defaults to `"beacon"` (event-driven). Set to `"polling"`
227
229
  * to restore the legacy fixed-interval poll.
@@ -742,6 +744,12 @@
742
744
  * @property {string} [path] - HTTP path for the built-in API manifest endpoint. Defaults to `/api/manifest`.
743
745
  * @property {string} [token] - Bearer token required in the `Authorization: Bearer <token>` header. When set, requests without a matching token are not routed (404), so the endpoint stays hidden.
744
746
  */
747
+ /**
748
+ * @typedef {object} McpConfiguration
749
+ * @property {string} [path] - HTTP path for the built-in MCP endpoint. Defaults to `/mcp`.
750
+ * @property {string} [token] - Bearer token required in the `Authorization` header. When set, requests without a matching token are not routed (404), so the endpoint stays hidden.
751
+ */
752
+
745
753
 
746
754
  /**
747
755
  * @typedef {object} DebugEndpointConfiguration
@@ -774,6 +782,7 @@
774
782
  * @property {boolean} [debug] - Enable debug logging.
775
783
  * @property {boolean | DebugEndpointConfiguration} [debugEndpoint] - Enable the built-in debug endpoint. Defaults to false.
776
784
  * @property {boolean | ApiManifestConfiguration} [apiManifest] - Enable the built-in API manifest endpoint. Defaults to false.
785
+ * @property {boolean | McpConfiguration} [mcp] - Enable the built-in MCP endpoint on the HTTP server (JSON-RPC over HTTP in addition to the `velocious mcp` stdio server). Defaults to false.
777
786
  * @property {string} [directory] - Base directory for the project.
778
787
  * @property {boolean} [enforceTenantDatabaseScopes] - Require tenant-switched model queries to resolve a tenant database identifier. Defaults to true.
779
788
  * @property {string} [environment] - Current environment name.
@@ -26,6 +26,7 @@ import UUID from "pure-uuid"
26
26
  import translate from "gettext-universal/build/src/translate.js"
27
27
  import Ability from "./authorization/ability.js"
28
28
  import BackgroundJobsAdapter from "./background-jobs/adapter.js"
29
+ import {DEFAULT_SCHEDULED_ENQUEUE_TIMEOUT_MS} from "./background-jobs/scheduled-enqueue-bound.js"
29
30
  import DatabaseOperation from "./database/operation.js"
30
31
  import { initializeAuditedModelRelationships } from "./database/record/auditing.js"
31
32
  import EventEmitter from "./utils/event-emitter.js"
@@ -259,7 +260,7 @@ export default class VelociousConfiguration {
259
260
  * Runs constructor.
260
261
  * @param {import("./configuration-types.js").ConfigurationArgsType} args - Configuration arguments.
261
262
  */
262
- constructor({abilityResolver, abilityResources, attachments, autoload = true, backgroundJobs, backendProjects, beacon, cookieSecret, cors, database, debug = false, debugEndpoint = false, apiManifest = false, directory, enforceTenantDatabaseScopes = true, environment, environmentHandler, exposeInternalErrorsToClients, frontendTenantSqlite, httpServer, initializeModels, initializers, locale, localeFallbacks, locales, logging, mailerBackend, packages, requestTimeoutMs, routeResolverHooks, scheduledBackgroundJobs, secureFrontendModelErrors, structureSql, sync, tenantDatabaseProviders, tenantDatabaseResolver, tenantResolver, testing, timeZone, timezoneOffsetMinutes, trustedProxies, websocketChannelResolver, websocketMessageHandlerResolver, ...restArgs}) {
263
+ constructor({abilityResolver, abilityResources, attachments, autoload = true, backgroundJobs, backendProjects, beacon, cookieSecret, cors, database, debug = false, debugEndpoint = false, apiManifest = false, directory, enforceTenantDatabaseScopes = true, environment, environmentHandler, exposeInternalErrorsToClients, frontendTenantSqlite, httpServer, initializeModels, initializers, locale, localeFallbacks, locales, logging, mailerBackend, mcp = false, packages, requestTimeoutMs, routeResolverHooks, scheduledBackgroundJobs, secureFrontendModelErrors, structureSql, sync, tenantDatabaseProviders, tenantDatabaseResolver, tenantResolver, testing, timeZone, timezoneOffsetMinutes, trustedProxies, websocketChannelResolver, websocketMessageHandlerResolver, ...restArgs}) {
263
264
  restArgsError(restArgs)
264
265
 
265
266
  this._abilityResolver = abilityResolver
@@ -303,6 +304,7 @@ export default class VelociousConfiguration {
303
304
  this.debug = debug
304
305
  this._debugEndpoint = this._normalizeDebugEndpoint(debugEndpoint)
305
306
  this._apiManifest = this._normalizeApiManifest(apiManifest)
307
+ this._mcp = this._normalizeMcp(mcp)
306
308
  this._environment = environment || globalThis.process?.env.VELOCIOUS_ENV || globalThis.process?.env.NODE_ENV || "development"
307
309
  this._environmentHandler = environmentHandler
308
310
  this._enforceTenantDatabaseScopes = enforceTenantDatabaseScopes
@@ -485,6 +487,7 @@ export default class VelociousConfiguration {
485
487
  this._routeResolverHooks = [...(routeResolverHooks || [])]
486
488
  this._addDebugEndpointRouteHook()
487
489
  this._addApiManifestRouteHook()
490
+ this._addMcpRouteHook()
488
491
 
489
492
  /**
490
493
  * Stores the applied route mounts value.
@@ -624,6 +627,63 @@ export default class VelociousConfiguration {
624
627
  })
625
628
  }
626
629
 
630
+ /**
631
+ * Runs normalize mcp.
632
+ * @param {boolean | {path?: string, token?: string}} value - MCP endpoint configuration.
633
+ * @returns {{enabled: boolean, path: string, token: string | null}} - Normalized MCP endpoint configuration.
634
+ */
635
+ _normalizeMcp(value) {
636
+ if (value === false || value === undefined) return {enabled: false, path: "/mcp", token: null}
637
+ if (value === true) return {enabled: true, path: "/mcp", token: null}
638
+
639
+ if (typeof value !== "object" || value === null) {
640
+ throw new Error(`Expected mcp to be a boolean or object, got: ${String(value)}`)
641
+ }
642
+
643
+ const path = value.path || "/mcp"
644
+
645
+ if (typeof path !== "string" || !path.startsWith("/")) {
646
+ throw new Error(`Expected mcp.path to be a string starting with '/', got: ${String(path)}`)
647
+ }
648
+
649
+ const token = value.token === undefined || value.token === null ? null : value.token
650
+
651
+ if (token !== null && (typeof token !== "string" || !token.trim())) {
652
+ throw new Error(`Expected mcp.token to be a non-empty string, got: ${String(token)}`)
653
+ }
654
+
655
+ return {enabled: true, path, token: token === null ? null : token.trim()}
656
+ }
657
+
658
+ /**
659
+ * Runs add mcp route hook.
660
+ * @returns {void} - No return value.
661
+ */
662
+ _addMcpRouteHook() {
663
+ if (!this._mcp.enabled) return
664
+
665
+ this.addRouteResolverHook(({currentPath, request}) => {
666
+ if (request.httpMethod() !== "POST") return null
667
+ if (currentPath !== this._mcp.path) return null
668
+
669
+ // When a token is configured, an unauthenticated request gets no route at
670
+ // all (404) rather than a 401, so the endpoint's existence stays hidden.
671
+ if (this._mcp.token && !this.debugEndpointRequestAuthorized(request, this._mcp.token)) return null
672
+
673
+ return {
674
+ action: "handle",
675
+ controller: "velociousMcp",
676
+ controllerPath: "./built-in/mcp/controller.js",
677
+ // Tenant and ability resolution belong to the frontend-model dispatcher
678
+ // per command (withFrontendModelRequestContext), exactly like the regular
679
+ // /frontend-models route — the MCP transport never bypasses them.
680
+ skipAbilityResolution: true,
681
+ skipTenantResolution: true,
682
+ viewPath: "./built-in/mcp"
683
+ }
684
+ })
685
+ }
686
+
627
687
  /**
628
688
  * Runs add debug endpoint route hook.
629
689
  * @returns {void} - No return value.
@@ -1041,6 +1101,7 @@ export default class VelociousConfiguration {
1041
1101
  enforceTenantDatabaseScopes: this.getEnforceTenantDatabaseScopes(),
1042
1102
  exposeInternalErrorsToClients: this.getExposeInternalErrorsToClients(),
1043
1103
  initialized: this._isInitialized,
1104
+ mcp: this._mcp.enabled ? {enabled: true, path: this._mcp.path, tokenConfigured: Boolean(this._mcp.token)} : {enabled: false},
1044
1105
  logging: {
1045
1106
  debugLowLevel: this._logging?.debugLowLevel === true,
1046
1107
  outputs: this._logging ? Object.keys(this._logging) : []
@@ -1707,6 +1768,20 @@ export default class VelociousConfiguration {
1707
1768
  const drainStoreOperationTimeoutMs = typeof configured.drainStoreOperationTimeoutMs === "number" && configured.drainStoreOperationTimeoutMs >= 1
1708
1769
  ? configured.drainStoreOperationTimeoutMs
1709
1770
  : 60_000
1771
+ // `null` is an explicit opt-out of the scheduled-enqueue bound. A present
1772
+ // but non-positive number is a mistake, not an opt-out, so it falls back to
1773
+ // the framework default rather than silently disabling the watchdog.
1774
+ const scheduledEnqueueTimeoutMs = "scheduledEnqueueTimeoutMs" in configured
1775
+ ? (
1776
+ configured.scheduledEnqueueTimeoutMs === null ?
1777
+ null
1778
+ : (
1779
+ typeof configured.scheduledEnqueueTimeoutMs === "number" && configured.scheduledEnqueueTimeoutMs > 0 ?
1780
+ configured.scheduledEnqueueTimeoutMs
1781
+ : DEFAULT_SCHEDULED_ENQUEUE_TIMEOUT_MS
1782
+ )
1783
+ )
1784
+ : DEFAULT_SCHEDULED_ENQUEUE_TIMEOUT_MS
1710
1785
  const queues = configured.queues && typeof configured.queues === "object" ? configured.queues : {}
1711
1786
  // An explicit config value wins over the env var — including `null`/`0`,
1712
1787
  // which disable the backstop even when the environment sets a default.
@@ -1732,7 +1807,7 @@ export default class VelociousConfiguration {
1732
1807
 
1733
1808
  const jobClasses = this.getBackgroundJobClasses()
1734
1809
 
1735
- return {host, port, databaseIdentifier, maxConcurrentForkedJobs, maxConcurrentInlineJobs, mode, pooledRunnerCount, pooledRunnerConcurrency, pooledRunnerMaxJobs, pooledRunnerMaxRssBytes, pooledRunnerMaxLifetimeMs, runnerMaxOldSpaceSizeMb, runnerMallocArenaMax, dispatchStrategy, pollIntervalMs, drainStoreOperationTimeoutMs, queues, jobClasses, jobTimeoutMs, retention, generationId, initialGenerationState, lifecycleSocketPath}
1810
+ return {host, port, databaseIdentifier, maxConcurrentForkedJobs, maxConcurrentInlineJobs, mode, pooledRunnerCount, pooledRunnerConcurrency, pooledRunnerMaxJobs, pooledRunnerMaxRssBytes, pooledRunnerMaxLifetimeMs, runnerMaxOldSpaceSizeMb, runnerMallocArenaMax, dispatchStrategy, pollIntervalMs, drainStoreOperationTimeoutMs, scheduledEnqueueTimeoutMs, queues, jobClasses, jobTimeoutMs, retention, generationId, initialGenerationState, lifecycleSocketPath}
1736
1811
  }
1737
1812
 
1738
1813
  /**
@@ -0,0 +1,16 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * Default bound for one blocking advisory-lock acquisition.
5
+ *
6
+ * A blocking `acquireAdvisoryLock(name)` call with no explicit timeout used to park a pooled
7
+ * connection for the driver's indefinite wait (MySQL/MariaDB: one year). One wedged holder — a
8
+ * killed process, a runner that died while holding the lock, a connection that never returned —
9
+ * then convoyed every later caller on the same lock name: the queue kept growing while every pool
10
+ * connection sat in MariaDB's `User lock` state and no dispatch happened anywhere.
11
+ *
12
+ * The default is deliberately far above any contention observed in healthy production operation, so
13
+ * a legitimate lock handover is never cut off, while a wedged holder stops stealing pool capacity
14
+ * forever. Pass `null` explicitly to opt back in to the indefinite wait.
15
+ * @type {number} */
16
+ export const DEFAULT_ADVISORY_LOCK_ACQUIRE_TIMEOUT_MS = 15 * 60 * 1000
@@ -160,6 +160,7 @@ import BacktraceCleaner from "../../utils/backtrace-cleaner.js"
160
160
  import { getDatabaseAnnotations } from "../annotations.js"
161
161
  import { formatDateForDatabase } from "../datetime-storage.js"
162
162
  import isDate from "../../utils/is-date.js"
163
+ import {DEFAULT_ADVISORY_LOCK_ACQUIRE_TIMEOUT_MS} from "../advisory-lock-bound.js"
163
164
  import Logger from "../../logger.js"
164
165
  import Query from "../query/index.js"
165
166
  import QueryAbortedError from "../query-aborted-error.js"
@@ -2928,17 +2929,37 @@ export default class VelociousDatabaseDriversBase {
2928
2929
  * same name and let you serialize functionality without blocking readers
2929
2930
  * or writers that do not participate in the same lock.
2930
2931
  * @param {string} name - Lock name.
2931
- * @param {{timeoutMs?: number | null}} [args] - Optional timeout in milliseconds; `null` or undefined blocks forever.
2932
+ * @param {{timeoutMs?: number | null}} [args] - Optional timeout in milliseconds; `null` blocks forever, and an omitted value uses the database's `advisoryLockAcquireTimeoutMs` or the framework default.
2932
2933
  * @returns {Promise<boolean>} - Resolves to true when the lock has been acquired, false if the timeout elapsed.
2933
2934
  */
2934
2935
  async acquireAdvisoryLock(name, args = {}) {
2935
- const acquired = await this._acquireAdvisoryLock(name, args)
2936
+ const acquired = await this._acquireAdvisoryLock(name, {timeoutMs: this.advisoryLockAcquireTimeoutMs(args)})
2936
2937
 
2937
2938
  if (acquired) this._trackAdvisoryLock(name)
2938
2939
 
2939
2940
  return acquired
2940
2941
  }
2941
2942
 
2943
+ /**
2944
+ * Resolves the wait bound for one advisory-lock acquisition.
2945
+ * An explicit `timeoutMs` — including an explicit `null` opt-out — always
2946
+ * wins. Otherwise the database configuration's `advisoryLockAcquireTimeoutMs`
2947
+ * applies, and a null there opts out too. Only when neither is configured does
2948
+ * the framework default bound the wait.
2949
+ * @param {{timeoutMs?: number | null}} [args] - Acquisition options from the caller.
2950
+ * @returns {number | null} - Wait bound in milliseconds, or `null` for an indefinite wait.
2951
+ */
2952
+ advisoryLockAcquireTimeoutMs(args = {}) {
2953
+ if (args.timeoutMs !== undefined) return args.timeoutMs
2954
+
2955
+ const configured = this.getArgs()?.advisoryLockAcquireTimeoutMs
2956
+
2957
+ if (configured === null) return null
2958
+ if (typeof configured === "number" && configured >= 0) return configured
2959
+
2960
+ return DEFAULT_ADVISORY_LOCK_ACQUIRE_TIMEOUT_MS
2961
+ }
2962
+
2942
2963
  /**
2943
2964
  * Driver-specific blocking advisory-lock acquisition hook.
2944
2965
  * @abstract
@@ -22,6 +22,11 @@ import Update from "./sql/update.js"
22
22
 
23
23
  const PG_TIMESTAMP_WITHOUT_TIMEZONE_OID = 1114
24
24
 
25
+ /** Smallest wait between `pg_try_advisory_lock` probes; preserves the historical 50ms cadence for short bounds. */
26
+ const PGSQL_ADVISORY_LOCK_POLL_MIN_INTERVAL_MS = 50
27
+ /** Largest wait between `pg_try_advisory_lock` probes; keeps a long bounded wait from probing tens of thousands of times. */
28
+ const PGSQL_ADVISORY_LOCK_POLL_MAX_INTERVAL_MS = 2000
29
+
25
30
  pgTypes.setTypeParser(PG_TIMESTAMP_WITHOUT_TIMEZONE_OID, (value) => new Date(`${value.replace(" ", "T")}Z`))
26
31
 
27
32
  export default class VelociousDatabaseDriversPgsql extends Base{
@@ -425,14 +430,32 @@ export default class VelociousDatabaseDriversPgsql extends Base{
425
430
  return signed.toString()
426
431
  }
427
432
 
433
+ /**
434
+ * Resolves the `pg_try_advisory_lock` probe cadence for one bounded wait.
435
+ *
436
+ * `pg_advisory_lock` has no native timeout, so a bounded acquisition polls. Scaling the interval
437
+ * with the bound keeps the historical 50ms cadence — and its fast acquisition — for the short
438
+ * explicit `timeoutMs` values callers already pass, while a long framework default stays
439
+ * responsive without turning one contended lock into tens of thousands of probe queries.
440
+ * @param {number} timeoutMs - Bounded wait in milliseconds.
441
+ * @returns {number} - Probe interval in milliseconds.
442
+ */
443
+ static advisoryLockPollIntervalMs(timeoutMs) {
444
+ return Math.min(
445
+ Math.max(Math.floor(timeoutMs / 100), PGSQL_ADVISORY_LOCK_POLL_MIN_INTERVAL_MS),
446
+ PGSQL_ADVISORY_LOCK_POLL_MAX_INTERVAL_MS
447
+ )
448
+ }
449
+
428
450
  /**
429
451
  * Blocks until a PostgreSQL session-level advisory lock is acquired on
430
452
  * this connection. Implemented via `pg_advisory_lock(bigint)`, which has
431
453
  * no native timeout — the `timeoutMs` argument is emulated by racing a
432
- * `pg_try_advisory_lock` poll loop so callers on MySQL and Postgres see
433
- * the same contract.
454
+ * `pg_try_advisory_lock` poll loop so callers on MySQL and Postgres see the
455
+ * same contract. The poll interval backs off to a cap so a long bounded wait
456
+ * does not turn one contended lock into thousands of probe queries.
434
457
  * @param {string} name - Lock name.
435
- * @param {{timeoutMs?: number | null}} [args] - Optional timeout in milliseconds; `null`, `undefined`, or negative blocks forever.
458
+ * @param {{timeoutMs?: number | null}} [args] - Optional timeout in milliseconds; `null` or negative blocks forever, and an omitted value uses the database's `advisoryLockAcquireTimeoutMs` or the framework default.
436
459
  * @returns {Promise<boolean>} - True if the lock was acquired, false if the timeout elapsed.
437
460
  */
438
461
  async _acquireAdvisoryLock(name, {timeoutMs} = {}) {
@@ -444,7 +467,7 @@ export default class VelociousDatabaseDriversPgsql extends Base{
444
467
  }
445
468
 
446
469
  const deadline = Date.now() + timeoutMs
447
- const pollIntervalMs = 50
470
+ const pollIntervalMs = VelociousDatabaseDriversPgsql.advisoryLockPollIntervalMs(timeoutMs)
448
471
 
449
472
  while (true) {
450
473
  if (await this._tryAcquireAdvisoryLock(name)) return true
@@ -518,6 +518,15 @@ export default class VelociousEnvironmentHandlerBase {
518
518
  throw new Error("cliCommandsConsole not implemented")
519
519
  }
520
520
 
521
+ /**
522
+ * Runs cli commands mcp.
523
+ * @param {import("../cli/base-command.js").default} _command - Command.
524
+ * @returns {Promise<void>} - Resolves when the command completes.
525
+ */
526
+ async cliCommandsMcp(_command) {
527
+ throw new Error("cliCommandsMcp not implemented")
528
+ }
529
+
521
530
  /**
522
531
  * Runs cli commands server.
523
532
  * @param {import("../cli/base-command.js").default} _command - Command.
@@ -0,0 +1,87 @@
1
+ // @ts-check
2
+
3
+ import Application from "../../../../application.js"
4
+ import BaseCommand from "../../../../cli/base-command.js"
5
+ import {startMcpStdioServer} from "../../../../mcp/json-rpc-server.js"
6
+
7
+ /**
8
+ * Parses `--request-context key=value` occurrences from raw process args and the
9
+ * `MCP_REQUEST_CONTEXT` environment variable (JSON object) into one context hash.
10
+ * @param {object} args - Arguments.
11
+ * @param {string[]} [args.processArgs] - Raw CLI process args.
12
+ * @returns {Record<string, string | number | boolean> | undefined} - Parsed request context, or undefined when none configured.
13
+ */
14
+ export function parseMcpRequestContext({processArgs = []}) {
15
+ /** @type {Record<string, string | number | boolean>} */
16
+ const context = {}
17
+
18
+ const envValue = process.env.MCP_REQUEST_CONTEXT
19
+
20
+ if (envValue) {
21
+ const parsedEnv = /** @type {Record<string, unknown>} */ (JSON.parse(envValue))
22
+
23
+ for (const [key, value] of Object.entries(parsedEnv)) {
24
+ assignContextValue(context, key, value)
25
+ }
26
+ }
27
+
28
+ for (let i = 0; i < processArgs.length; i++) {
29
+ if (processArgs[i] !== "--request-context") continue
30
+
31
+ const pair = processArgs[i + 1]
32
+
33
+ if (typeof pair !== "string") throw new Error("--request-context requires a key=value pair")
34
+
35
+ const separatorIndex = pair.indexOf("=")
36
+
37
+ if (separatorIndex < 1) throw new Error(`--request-context pair '${pair}' must be key=value`)
38
+
39
+ assignContextValue(context, pair.slice(0, separatorIndex), pair.slice(separatorIndex + 1))
40
+ i++
41
+ }
42
+
43
+ return Object.keys(context).length > 0 ? context : undefined
44
+ }
45
+
46
+ /**
47
+ * Assigns one scalar context value, converting numeric strings to numbers.
48
+ * @param {Record<string, string | number | boolean>} context - Target context hash.
49
+ * @param {string} key - Context key.
50
+ * @param {unknown} value - Raw value.
51
+ * @returns {void} - No return value.
52
+ */
53
+ function assignContextValue(context, key, value) {
54
+ if (typeof value === "number" || typeof value === "boolean") {
55
+ context[key] = value
56
+
57
+ return
58
+ }
59
+
60
+ if (typeof value !== "string") throw new Error(`Request context value for '${key}' must be a scalar`)
61
+
62
+ const asNumber = Number(value)
63
+
64
+ context[key] = value.length > 0 && Number.isFinite(asNumber) ? asNumber : value
65
+ }
66
+
67
+ export default class VelociousCliCommandsMcp extends BaseCommand{
68
+ /**
69
+ * Runs execute.
70
+ * @returns {Promise<void>} - Starts the MCP stdio server and waits until stdin closes.
71
+ */
72
+ async execute() {
73
+ // Identify this process in `ps`/`top` instead of a generic "node" entry.
74
+ process.title = "velocious mcp"
75
+
76
+ const configuration = this.getConfiguration()
77
+ const requestContext = parseMcpRequestContext({processArgs: this.processArgs})
78
+ const application = new Application({configuration, type: "mcp"})
79
+
80
+ await application.initialize()
81
+
82
+ const server = startMcpStdioServer({configuration, requestContext})
83
+
84
+ await server.done
85
+ await application.stop()
86
+ }
87
+ }