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.
- package/README.md +5 -0
- package/build/background-jobs/scheduled-enqueue-bound.js +18 -0
- package/build/background-jobs/scheduled-enqueue-timeout-error.js +25 -0
- package/build/background-jobs/scheduler.js +121 -4
- package/build/cli/commands/mcp.js +7 -0
- package/build/cli/index.js +1 -1
- package/build/configuration-types.js +9 -0
- package/build/configuration.js +77 -2
- package/build/database/advisory-lock-bound.js +16 -0
- package/build/database/drivers/base.js +23 -2
- package/build/database/drivers/pgsql/index.js +27 -4
- package/build/environment-handlers/base.js +9 -0
- package/build/environment-handlers/node/cli/commands/mcp.js +87 -0
- package/build/environment-handlers/node.js +10 -0
- package/build/mcp/dispatcher.js +271 -0
- package/build/mcp/json-rpc-server.js +167 -0
- package/build/mcp/manifest-tools.js +310 -0
- package/build/routes/built-in/mcp/controller.js +63 -0
- package/build/routes/catch-all-route.js +58 -0
- package/build/routes/resolver.js +21 -8
- package/build/routes/root-route.js +26 -1
- package/build/src/background-jobs/scheduled-enqueue-bound.d.ts +15 -0
- package/build/src/background-jobs/scheduled-enqueue-bound.d.ts.map +1 -0
- package/build/src/background-jobs/scheduled-enqueue-bound.js +17 -0
- package/build/src/background-jobs/scheduled-enqueue-timeout-error.d.ts +24 -0
- package/build/src/background-jobs/scheduled-enqueue-timeout-error.d.ts.map +1 -0
- package/build/src/background-jobs/scheduled-enqueue-timeout-error.js +23 -0
- package/build/src/background-jobs/scheduler.d.ts +40 -0
- package/build/src/background-jobs/scheduler.d.ts.map +1 -1
- package/build/src/background-jobs/scheduler.js +111 -5
- package/build/src/cli/commands/mcp.d.ts +5 -0
- package/build/src/cli/commands/mcp.d.ts.map +1 -0
- package/build/src/cli/commands/mcp.js +7 -0
- package/build/src/cli/index.js +2 -2
- package/build/src/configuration-types.d.ts +30 -0
- package/build/src/configuration-types.d.ts.map +1 -1
- package/build/src/configuration-types.js +9 -1
- package/build/src/configuration.d.ts +24 -1
- package/build/src/configuration.d.ts.map +1 -1
- package/build/src/configuration.js +69 -3
- package/build/src/database/advisory-lock-bound.d.ts +15 -0
- package/build/src/database/advisory-lock-bound.d.ts.map +1 -0
- package/build/src/database/advisory-lock-bound.js +16 -0
- package/build/src/database/drivers/base.d.ts +13 -1
- package/build/src/database/drivers/base.d.ts.map +1 -1
- package/build/src/database/drivers/base.js +23 -3
- package/build/src/database/drivers/pgsql/index.d.ts +15 -3
- package/build/src/database/drivers/pgsql/index.d.ts.map +1 -1
- package/build/src/database/drivers/pgsql/index.js +23 -5
- package/build/src/environment-handlers/base.d.ts +6 -0
- package/build/src/environment-handlers/base.d.ts.map +1 -1
- package/build/src/environment-handlers/base.js +9 -1
- package/build/src/environment-handlers/node/cli/commands/mcp.d.ts +19 -0
- package/build/src/environment-handlers/node/cli/commands/mcp.d.ts.map +1 -0
- package/build/src/environment-handlers/node/cli/commands/mcp.js +70 -0
- package/build/src/environment-handlers/node.d.ts +6 -0
- package/build/src/environment-handlers/node.d.ts.map +1 -1
- package/build/src/environment-handlers/node.js +10 -1
- package/build/src/mcp/dispatcher.d.ts +133 -0
- package/build/src/mcp/dispatcher.d.ts.map +1 -0
- package/build/src/mcp/dispatcher.js +232 -0
- package/build/src/mcp/json-rpc-server.d.ts +47 -0
- package/build/src/mcp/json-rpc-server.d.ts.map +1 -0
- package/build/src/mcp/json-rpc-server.js +142 -0
- package/build/src/mcp/manifest-tools.d.ts +76 -0
- package/build/src/mcp/manifest-tools.d.ts.map +1 -0
- package/build/src/mcp/manifest-tools.js +273 -0
- package/build/src/routes/built-in/mcp/controller.d.ts +14 -0
- package/build/src/routes/built-in/mcp/controller.d.ts.map +1 -0
- package/build/src/routes/built-in/mcp/controller.js +55 -0
- package/build/src/routes/catch-all-route.d.ts +48 -0
- package/build/src/routes/catch-all-route.d.ts.map +1 -0
- package/build/src/routes/catch-all-route.js +50 -0
- package/build/src/routes/resolver.d.ts.map +1 -1
- package/build/src/routes/resolver.js +23 -9
- package/build/src/routes/root-route.d.ts +15 -1
- package/build/src/routes/root-route.d.ts.map +1 -1
- package/build/src/routes/root-route.js +24 -2
- package/package.json +1 -1
- package/src/background-jobs/scheduled-enqueue-bound.js +18 -0
- package/src/background-jobs/scheduled-enqueue-timeout-error.js +25 -0
- package/src/background-jobs/scheduler.js +121 -4
- package/src/cli/commands/mcp.js +7 -0
- package/src/cli/index.js +1 -1
- package/src/configuration-types.js +9 -0
- package/src/configuration.js +77 -2
- package/src/database/advisory-lock-bound.js +16 -0
- package/src/database/drivers/base.js +23 -2
- package/src/database/drivers/pgsql/index.js +27 -4
- package/src/environment-handlers/base.js +9 -0
- package/src/environment-handlers/node/cli/commands/mcp.js +87 -0
- package/src/environment-handlers/node.js +10 -0
- package/src/mcp/dispatcher.js +271 -0
- package/src/mcp/json-rpc-server.js +167 -0
- package/src/mcp/manifest-tools.js +310 -0
- package/src/routes/built-in/mcp/controller.js +63 -0
- package/src/routes/catch-all-route.js +58 -0
- package/src/routes/resolver.js +21 -8
- package/src/routes/root-route.js +26 -1
package/src/configuration.js
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
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
|
|
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 =
|
|
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
|
+
}
|
|
@@ -11,6 +11,7 @@ import CliCommandsGenerateFrontendModels from "./node/cli/commands/generate/fron
|
|
|
11
11
|
import CliCommandsGenerateMigration from "./node/cli/commands/generate/migration.js"
|
|
12
12
|
import CliCommandsGenerateModel from "./node/cli/commands/generate/model.js"
|
|
13
13
|
import CliCommandsLintRelationships from "./node/cli/commands/lint/relationships.js"
|
|
14
|
+
import CliCommandsMcp from "./node/cli/commands/mcp.js"
|
|
14
15
|
import CliCommandsRoutes from "./node/cli/commands/routes.js"
|
|
15
16
|
import CliCommandsServer from "./node/cli/commands/server.js"
|
|
16
17
|
import CliCommandsTest from "./node/cli/commands/test.js"
|
|
@@ -859,6 +860,15 @@ export default class VelociousEnvironmentHandlerNode extends Base{
|
|
|
859
860
|
return await this.forwardCommand(command, CliCommandsServer)
|
|
860
861
|
}
|
|
861
862
|
|
|
863
|
+
/**
|
|
864
|
+
* Runs cli commands mcp.
|
|
865
|
+
* @param {import("../cli/base-command.js").default} command - Command.
|
|
866
|
+
* @returns {Promise<void>} - Resolves when the command completes.
|
|
867
|
+
*/
|
|
868
|
+
async cliCommandsMcp(command) {
|
|
869
|
+
return await this.forwardCommand(command, CliCommandsMcp)
|
|
870
|
+
}
|
|
871
|
+
|
|
862
872
|
/**
|
|
863
873
|
* Runs cli commands test.
|
|
864
874
|
* @param {import("../cli/base-command.js").default} command - Command.
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @typedef {object} McpToolResult
|
|
5
|
+
* @property {Array<{text: string, type: "text"}>} content - Tool result content blocks.
|
|
6
|
+
* @property {boolean} isError - Whether the tool call failed.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {object} FrontendModelRequestEntry
|
|
11
|
+
* @property {string} [commandType] - Built-in command type.
|
|
12
|
+
* @property {string} [customPath] - Custom command path.
|
|
13
|
+
* @property {string} model - Backend model name.
|
|
14
|
+
* @property {Record<string, unknown>} payload - Command payload.
|
|
15
|
+
* @property {Record<string, string | number | boolean> | undefined} [requestContext] - Remote request context.
|
|
16
|
+
* @property {string} requestId - Request correlation id.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @typedef {Record<string, unknown> & {formatVersion?: number, resources?: Record<string, unknown>}} ApiManifest
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @typedef {object} ManifestCommandEntry
|
|
25
|
+
* @property {Array<{name: string, type: string}>} [args] - Declared typed command arguments.
|
|
26
|
+
* @property {boolean} [isBuiltIn] - Whether the entry is a synthesized built-in command.
|
|
27
|
+
* @property {string} methodName - Command method name.
|
|
28
|
+
* @property {string} [path] - Manifest command path (member paths carry `<id>`).
|
|
29
|
+
* @property {string | null} [returnType] - Declared JSDoc return type.
|
|
30
|
+
* @property {"collection" | "member"} scope - Command scope.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @typedef {object} ManifestResourceEntry
|
|
35
|
+
* @property {Record<string, ManifestCommandEntry[]>} [commands] - Scope-keyed custom command entries.
|
|
36
|
+
* @property {Record<string, Record<string, string>>} [builtInCommands] - Scope-keyed built-in command maps.
|
|
37
|
+
* @property {string} modelName - Backend model name.
|
|
38
|
+
* @property {string | string[]} [primaryKey] - Primary key attribute name(s).
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import FrontendModelController from "../frontend-model-controller.js"
|
|
42
|
+
import Request from "../http-server/client/request.js"
|
|
43
|
+
import Response from "../http-server/client/response.js"
|
|
44
|
+
import {deserializeFrontendModelTransportValue, serializeFrontendModelTransportValue} from "../frontend-models/transport-serialization.js"
|
|
45
|
+
import {generateMcpTools, metaToolName, parseMcpToolName} from "./manifest-tools.js"
|
|
46
|
+
|
|
47
|
+
const MCP_REQUEST_PATH = "/frontend-models"
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Lists the MCP tools exposed by a configuration's frontend-model API manifest.
|
|
51
|
+
* @param {object} args - Arguments.
|
|
52
|
+
* @param {import("../configuration.js").default} args.configuration - Configuration instance.
|
|
53
|
+
* @returns {Promise<Record<string, unknown>[]>} - MCP `tools/list` tool definitions.
|
|
54
|
+
*/
|
|
55
|
+
export async function listMcpTools({configuration}) {
|
|
56
|
+
const manifest = /** @type {ApiManifest} */ (await configuration.getApiManifest())
|
|
57
|
+
|
|
58
|
+
return generateMcpTools(manifest)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Executes one MCP `tools/call` against the frontend-model dispatcher.
|
|
63
|
+
* The tool call is translated into the exact shared frontend-model request entry
|
|
64
|
+
* (`commandType`/`customPath`, `model`, `payload`, `requestContext`) and runs
|
|
65
|
+
* through `FrontendModelController.frontendApi()` in-process, so tenant and
|
|
66
|
+
* ability resolution stay inside the owning dispatcher — MCP never bypasses them.
|
|
67
|
+
* @param {object} args - Arguments.
|
|
68
|
+
* @param {import("../configuration.js").default} args.configuration - Configuration instance.
|
|
69
|
+
* @param {Record<string, string | number | boolean> | undefined} [args.requestContext] - Remote request context forwarded to the app's ability/tenant resolvers.
|
|
70
|
+
* @param {Record<string, unknown>} args.toolArguments - Tool arguments from the MCP client.
|
|
71
|
+
* @param {string} args.toolName - MCP tool name.
|
|
72
|
+
* @returns {Promise<McpToolResult>} - MCP tool call result.
|
|
73
|
+
*/
|
|
74
|
+
export async function callMcpTool({configuration, requestContext, toolArguments, toolName}) {
|
|
75
|
+
const manifest = /** @type {ApiManifest} */ (await configuration.getApiManifest())
|
|
76
|
+
|
|
77
|
+
if (toolName === metaToolName()) {
|
|
78
|
+
return textResult({value: manifest})
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const identity = parseMcpToolName({manifest, toolName})
|
|
82
|
+
|
|
83
|
+
if (!identity) return errorResult(`Unknown MCP tool '${toolName}'.`)
|
|
84
|
+
|
|
85
|
+
const manifestResources = /** @type {Record<string, unknown>} */ (manifest.resources || {})
|
|
86
|
+
const resource = /** @type {ManifestResourceEntry} */ (manifestResources[identity.modelKey])
|
|
87
|
+
const commandEntry = findManifestCommandEntry({commandName: identity.commandName, resource, scope: identity.scope})
|
|
88
|
+
|
|
89
|
+
if (!commandEntry) return errorResult(`Manifest command entry missing for tool '${toolName}'.`)
|
|
90
|
+
|
|
91
|
+
const requestEntry = buildFrontendModelRequestEntry({commandEntry, model: String(resource.modelName), requestContext, toolArguments})
|
|
92
|
+
const responsePayload = await runFrontendApiRequest({configuration, requestEntry})
|
|
93
|
+
const responseEntry = Array.isArray(responsePayload?.responses) ? responsePayload.responses[0] : undefined
|
|
94
|
+
const commandResponse = responseEntry?.response
|
|
95
|
+
|
|
96
|
+
if (!commandResponse) return errorResult("Frontend-model dispatcher returned no response.")
|
|
97
|
+
|
|
98
|
+
if (commandResponse.status === "error") {
|
|
99
|
+
return errorResult(commandResponse.errorMessage || "Frontend-model command failed.", commandResponse)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return textResult({value: commandResponse})
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Finds the manifest command entry matching a parsed tool identity.
|
|
107
|
+
* @param {object} args - Arguments.
|
|
108
|
+
* @param {string} args.commandName - Command method name.
|
|
109
|
+
* @param {ManifestResourceEntry} args.resource - Manifest resource entry.
|
|
110
|
+
* @param {"collection" | "member"} args.scope - Command scope.
|
|
111
|
+
* @returns {ManifestCommandEntry | undefined} - Manifest command entry.
|
|
112
|
+
*/
|
|
113
|
+
function findManifestCommandEntry({commandName, resource, scope}) {
|
|
114
|
+
if (resource.builtInCommands?.[scope]?.[commandName]) {
|
|
115
|
+
return {isBuiltIn: true, methodName: commandName, scope}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const scopeCommands = resource.commands?.[scope]
|
|
119
|
+
|
|
120
|
+
if (!Array.isArray(scopeCommands)) return undefined
|
|
121
|
+
|
|
122
|
+
return scopeCommands.find((entry) => entry.methodName === commandName)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Builds the shared frontend-model request entry for one tool call.
|
|
127
|
+
* @param {object} args - Arguments.
|
|
128
|
+
* @param {ManifestCommandEntry} args.commandEntry - Manifest command entry.
|
|
129
|
+
* @param {string} args.model - Backend model name.
|
|
130
|
+
* @param {Record<string, string | number | boolean> | undefined} args.requestContext - Remote request context.
|
|
131
|
+
* @param {Record<string, unknown>} args.toolArguments - Tool arguments.
|
|
132
|
+
* @returns {FrontendModelRequestEntry} - frontendApi request entry.
|
|
133
|
+
*/
|
|
134
|
+
function buildFrontendModelRequestEntry({commandEntry, model, requestContext, toolArguments}) {
|
|
135
|
+
const isBuiltInCommand = ["attach", "attachmentList", "create", "destroy", "download", "find", "index", "update", "url"].includes(commandEntry.methodName)
|
|
136
|
+
|
|
137
|
+
if (isBuiltInCommand) {
|
|
138
|
+
return {
|
|
139
|
+
commandType: commandEntry.methodName,
|
|
140
|
+
model,
|
|
141
|
+
payload: toolArguments,
|
|
142
|
+
requestId: "mcp-1",
|
|
143
|
+
requestContext
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
customPath: resolveCustomCommandPath({commandEntry, toolArguments}),
|
|
149
|
+
model,
|
|
150
|
+
payload: toolArguments,
|
|
151
|
+
requestId: "mcp-1",
|
|
152
|
+
requestContext
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Substitutes the `<id>` placeholder of a manifest command path with the tool's
|
|
158
|
+
* primary key argument, matching the member route the dispatcher resolves.
|
|
159
|
+
* @param {object} args - Arguments.
|
|
160
|
+
* @param {ManifestCommandEntry} args.commandEntry - Manifest command entry (carries `path` and `scope`).
|
|
161
|
+
* @param {Record<string, unknown>} args.toolArguments - Tool arguments.
|
|
162
|
+
* @returns {string} - Custom command path.
|
|
163
|
+
*/
|
|
164
|
+
function resolveCustomCommandPath({commandEntry, toolArguments}) {
|
|
165
|
+
const path = String(commandEntry.path || "")
|
|
166
|
+
|
|
167
|
+
if (!path.includes("<id>")) return path
|
|
168
|
+
|
|
169
|
+
const idValue = toolArguments.id ?? toolArguments.recordId
|
|
170
|
+
|
|
171
|
+
return path.replace("<id>", encodeURIComponent(String(idValue ?? "")))
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Builds the synthetic HTTP request lines for one MCP dispatch. The request-context
|
|
176
|
+
* `authorization` key is forwarded as the request's `Authorization` header so apps
|
|
177
|
+
* whose ability resolver authenticates from headers (Bearer service tokens or
|
|
178
|
+
* personal access tokens) authenticate MCP tool calls exactly like HTTP calls.
|
|
179
|
+
* Carriage returns and line feeds are stripped so a context value can never inject
|
|
180
|
+
* extra request lines.
|
|
181
|
+
* @param {object} args - Arguments.
|
|
182
|
+
* @param {Record<string, string | number | boolean> | undefined} args.requestContext - Remote request context.
|
|
183
|
+
* @returns {string} - Raw HTTP request head.
|
|
184
|
+
*/
|
|
185
|
+
export function buildSyntheticRequestLines({requestContext}) {
|
|
186
|
+
const lines = [
|
|
187
|
+
`POST ${MCP_REQUEST_PATH} HTTP/1.1`,
|
|
188
|
+
"Host: velocious-mcp",
|
|
189
|
+
"Content-Length: 0",
|
|
190
|
+
]
|
|
191
|
+
|
|
192
|
+
if (requestContext && typeof requestContext.authorization === "string") {
|
|
193
|
+
const authorization = requestContext.authorization.replace(/[\r\n]/g, "").trim()
|
|
194
|
+
|
|
195
|
+
if (authorization.length > 0) lines.push(`Authorization: ${authorization}`)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
lines.push("", "")
|
|
199
|
+
|
|
200
|
+
return lines.join("\r\n")
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Runs one frontend-model request entry through the controller in-process,
|
|
205
|
+
* mirroring the synthetic-request pattern used by the controller specs.
|
|
206
|
+
* @param {object} args - Arguments.
|
|
207
|
+
* @param {import("../configuration.js").default} args.configuration - Configuration instance.
|
|
208
|
+
* @param {FrontendModelRequestEntry} args.requestEntry - frontendApi request entry.
|
|
209
|
+
* @returns {Promise<Record<string, ReturnType<typeof JSON.parse>>>} - Parsed frontendApi response payload.
|
|
210
|
+
*/
|
|
211
|
+
async function runFrontendApiRequest({configuration, requestEntry}) {
|
|
212
|
+
const client = /** @type {import("../http-server/client/index.js").default} */ ({remoteAddress: "127.0.0.1"})
|
|
213
|
+
const request = new Request({client, configuration})
|
|
214
|
+
const donePromise = new Promise((resolve) => request.requestParser.events.on("done", resolve))
|
|
215
|
+
|
|
216
|
+
request.feed(Buffer.from(buildSyntheticRequestLines({requestContext: requestEntry.requestContext}), "utf8"))
|
|
217
|
+
|
|
218
|
+
await donePromise
|
|
219
|
+
|
|
220
|
+
const response = new Response({configuration})
|
|
221
|
+
const controller = new FrontendModelController({
|
|
222
|
+
action: "frontendApi",
|
|
223
|
+
configuration,
|
|
224
|
+
controller: "frontend-models",
|
|
225
|
+
params: {requests: [requestEntry]},
|
|
226
|
+
request,
|
|
227
|
+
response,
|
|
228
|
+
viewPath: `${configuration.getDirectory()}/src/routes/frontend-models`
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
await controller.frontendApi()
|
|
232
|
+
|
|
233
|
+
const body = response.getBody()
|
|
234
|
+
const responseText = body === null || body === undefined
|
|
235
|
+
? ""
|
|
236
|
+
: (typeof body === "string" ? body : Buffer.from(body).toString("utf8"))
|
|
237
|
+
const responseJson = responseText.length > 0 ? JSON.parse(responseText) : {}
|
|
238
|
+
|
|
239
|
+
return /** @type {Record<string, ReturnType<typeof JSON.parse>>} */ (deserializeFrontendModelTransportValue(responseJson))
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Wraps a value as an MCP text content result.
|
|
244
|
+
* @param {object} args - Arguments.
|
|
245
|
+
* @param {unknown} args.value - Value to serialize as JSON text.
|
|
246
|
+
* @returns {McpToolResult} - MCP tool result.
|
|
247
|
+
*/
|
|
248
|
+
function textResult({value}) {
|
|
249
|
+
const serialized = serializeFrontendModelTransportValue(value)
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
content: [{text: JSON.stringify(serialized, null, 2), type: "text"}],
|
|
253
|
+
isError: false
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Builds an MCP error result, optionally including the dispatcher's error
|
|
259
|
+
* payload (validation summaries stay faithful — nothing is swallowed).
|
|
260
|
+
* @param {string} message - Error message.
|
|
261
|
+
* @param {Record<string, unknown>} [payload] - Full dispatcher error payload.
|
|
262
|
+
* @returns {McpToolResult} - MCP tool result.
|
|
263
|
+
*/
|
|
264
|
+
function errorResult(message, payload) {
|
|
265
|
+
const detail = payload ? `\n${JSON.stringify(payload, null, 2)}` : ""
|
|
266
|
+
|
|
267
|
+
return {
|
|
268
|
+
content: [{text: `${message}${detail}`, type: "text"}],
|
|
269
|
+
isError: true
|
|
270
|
+
}
|
|
271
|
+
}
|