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
@@ -0,0 +1,167 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * @typedef {Record<string, unknown> & {error?: {code: number, message: string}, id?: number | string | null, jsonrpc: string, method?: string, params?: Record<string, unknown>, result?: Record<string, unknown>}} JsonRpcMessage
5
+ */
6
+
7
+ import readline from "node:readline"
8
+ import {callMcpTool, listMcpTools} from "./dispatcher.js"
9
+
10
+ const MCP_PROTOCOL_VERSION = "2025-03-26"
11
+
12
+ /**
13
+ * Handles one JSON-RPC message of the MCP protocol (initialize / tools/list /
14
+ * tools/call / ping) against a Velocious configuration. Returns null for
15
+ * notifications (no response expected).
16
+ * @param {object} args - Arguments.
17
+ * @param {import("../configuration.js").default} args.configuration - Configuration instance.
18
+ * @param {JsonRpcMessage} args.request - Parsed JSON-RPC request.
19
+ * @param {Record<string, string | number | boolean> | undefined} [args.requestContext] - Remote request context forwarded to every tool call.
20
+ * @returns {Promise<JsonRpcMessage | null>} - JSON-RPC response, or null for notifications.
21
+ */
22
+ export async function handleMcpRequest({configuration, request, requestContext}) {
23
+ if (!request || typeof request !== "object" || request.jsonrpc !== "2.0" || typeof request.method !== "string") {
24
+ return jsonRpcError(null, -32600, "Invalid Request")
25
+ }
26
+
27
+ const id = request.id
28
+
29
+ if (request.method.startsWith("notifications/")) return null
30
+
31
+ if (request.method === "initialize") {
32
+ return jsonRpcResult(id, {
33
+ capabilities: {tools: {listChanged: false}},
34
+ protocolVersion: MCP_PROTOCOL_VERSION,
35
+ serverInfo: {name: "velocious-mcp", title: "Velocious frontend-model MCP server", version: "1.0.0"}
36
+ })
37
+ }
38
+
39
+ if (request.method === "ping") return jsonRpcResult(id, {})
40
+
41
+ if (request.method === "tools/list") {
42
+ return jsonRpcResult(id, {tools: await listMcpTools({configuration})})
43
+ }
44
+
45
+ if (request.method === "tools/call") {
46
+ const params = request.params || {}
47
+
48
+ if (typeof params.name !== "string") return jsonRpcError(id, -32602, "Expected tools/call params.name")
49
+
50
+ const toolArguments = params.arguments && typeof params.arguments === "object" && !Array.isArray(params.arguments)
51
+ ? /** @type {Record<string, unknown>} */ (params.arguments)
52
+ : {}
53
+
54
+ return jsonRpcResult(id, await callMcpTool({configuration, requestContext, toolArguments, toolName: params.name}))
55
+ }
56
+
57
+ return jsonRpcError(id, -32601, `Method not found: ${request.method}`)
58
+ }
59
+
60
+ /**
61
+ * Starts a line-delimited JSON-RPC MCP server over stdio (the primary MCP
62
+ * transport for local tool servers). Each input line is one JSON-RPC message;
63
+ * each response is written as one JSON line.
64
+ * @param {object} args - Arguments.
65
+ * @param {import("../configuration.js").default} args.configuration - Configuration instance.
66
+ * @param {import("node:stream").Readable} [args.input] - Input stream (defaults to stdin).
67
+ * @param {import("node:stream").Writable} [args.output] - Output stream (defaults to stdout).
68
+ * @param {Record<string, string | number | boolean> | undefined} [args.requestContext] - Remote request context forwarded to every tool call.
69
+ * @returns {{close: () => void, done: Promise<void>}} - Server handle; `done` resolves when the input stream closes.
70
+ */
71
+ export function startMcpStdioServer({configuration, input = process.stdin, output = process.stdout, requestContext}) {
72
+ let closed = false
73
+ let doneResolved = false
74
+ /** @type {((value: void) => void) | undefined} */
75
+ let resolveDone
76
+
77
+ const done = new Promise((resolve) => { resolveDone = resolve })
78
+
79
+ /**
80
+ * Writes one JSON-RPC message as a line.
81
+ * @param {JsonRpcMessage | null} message - Message to write.
82
+ * @returns {void} - No return value.
83
+ */
84
+ const writeMessage = (message) => {
85
+ // Responses for in-flight requests are still written after the input
86
+ // stream closed; only after `done` resolved are writes suppressed.
87
+ if (message === null || doneResolved) return
88
+
89
+ output.write(`${JSON.stringify(message)}\n`)
90
+ }
91
+
92
+ const readlineInterface = readline.createInterface({input})
93
+
94
+ let pendingRequests = 0
95
+
96
+ const finishIfClosed = () => {
97
+ if (closed && pendingRequests === 0 && resolveDone && !doneResolved) {
98
+ doneResolved = true
99
+ resolveDone(undefined)
100
+ }
101
+ }
102
+
103
+ readlineInterface.on("line", (/** @type {string} */ line) => {
104
+ const trimmed = line.trim()
105
+
106
+ if (trimmed.length === 0) return
107
+
108
+ /** @type {JsonRpcMessage} */
109
+ let request
110
+
111
+ try {
112
+ request = JSON.parse(trimmed)
113
+ } catch {
114
+ writeMessage(jsonRpcError(null, -32700, "Parse error"))
115
+
116
+ return
117
+ }
118
+
119
+ pendingRequests++
120
+
121
+ handleMcpRequest({configuration, request, requestContext}).then((response) => {
122
+ writeMessage(response)
123
+ }).catch((error) => {
124
+ writeMessage(jsonRpcError(request.id ?? null, -32603, `Internal error: ${error instanceof Error ? error.message : String(error)}`))
125
+ }).finally(() => {
126
+ pendingRequests--
127
+ finishIfClosed()
128
+ })
129
+ })
130
+
131
+ readlineInterface.on("close", () => {
132
+ closed = true
133
+ finishIfClosed()
134
+ })
135
+
136
+ return {
137
+ close() {
138
+ if (closed) return
139
+
140
+ closed = true
141
+ readlineInterface.close()
142
+ finishIfClosed()
143
+ },
144
+ done
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Builds a JSON-RPC success response.
150
+ * @param {number | string | null | undefined} id - Request id.
151
+ * @param {Record<string, unknown>} result - Result payload.
152
+ * @returns {JsonRpcMessage} - JSON-RPC response.
153
+ */
154
+ function jsonRpcResult(id, result) {
155
+ return {id, jsonrpc: "2.0", result}
156
+ }
157
+
158
+ /**
159
+ * Builds a JSON-RPC error response.
160
+ * @param {number | string | null | undefined} id - Request id.
161
+ * @param {number} code - JSON-RPC error code.
162
+ * @param {string} message - Error message.
163
+ * @returns {JsonRpcMessage} - JSON-RPC response.
164
+ */
165
+ function jsonRpcError(id, code, message) {
166
+ return {error: {code, message}, id, jsonrpc: "2.0"}
167
+ }
@@ -0,0 +1,310 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * @typedef {object} ManifestCommandEntry
5
+ * @property {Array<{name: string, type: string}>} [args] - Declared typed command arguments.
6
+ * @property {string} methodName - Command method name.
7
+ * @property {string} [path] - Manifest command path (member paths carry `<id>`).
8
+ * @property {string | null} [returnType] - Declared JSDoc return type.
9
+ * @property {"collection" | "member"} scope - Command scope.
10
+ */
11
+
12
+ /**
13
+ * @typedef {object} ManifestResourceEntry
14
+ * @property {Record<string, ManifestCommandEntry[]>} [commands] - Scope-keyed custom command entries.
15
+ * @property {Record<string, Record<string, string>>} [builtInCommands] - Scope-keyed built-in command maps (methodName → slug).
16
+ * @property {string} modelName - Backend model name.
17
+ * @property {string | string[]} [primaryKey] - Primary key attribute name(s).
18
+ */
19
+
20
+ /**
21
+ * @typedef {Record<string, unknown> & {formatVersion?: number, resources?: Record<string, unknown>}} ApiManifest
22
+ */
23
+
24
+ import inflection from "inflection"
25
+
26
+ const MCP_TOOL_NAME_PATTERN = /^[a-z0-9_]+$/
27
+
28
+ /** @type {Record<string, string>} */
29
+ const BUILT_IN_COMMAND_DESCRIPTIONS = {
30
+ attach: "Attach a file attachment to one record.",
31
+ attachmentList: "List the attachments of one record.",
32
+ create: "Create one record with `attributes` (and optional `nestedAttributes`).",
33
+ destroy: "Destroy one record by primary key `id`.",
34
+ download: "Download one attachment of one record.",
35
+ find: "Find one record by primary key `id`.",
36
+ index: "List records with optional `where`, `searches`, `sort`, `limit`, `offset`, `page`, `perPage`, `preload`, `select`, `count`, `pluck`, `distinct`, `group`, `joins`, and `queryData`.",
37
+ update: "Update one record by primary key `id` with `attributes` (and optional `nestedAttributes`).",
38
+ url: "Resolve the download URL of one attachment of one record."
39
+ }
40
+
41
+ const META_TOOL_NAME = "frontend_model_api_manifest"
42
+ const META_TOOL_DESCRIPTION = "Return the full frontend-model API manifest (all resources, attributes, abilities, and commands) so callers can discover the available model commands."
43
+
44
+ /**
45
+ * Maps one JSDoc type string from command metadata to a JSON Schema fragment.
46
+ * @param {string} type - JSDoc type string (for example `string`, `number`, `{id: number}`).
47
+ * @returns {{items?: {type: string}, type: string}} - JSON Schema type fragment.
48
+ */
49
+ function jsonSchemaTypeForJSDocType(type) {
50
+ const normalizedType = type.trim()
51
+
52
+ if (normalizedType === "number" || normalizedType === "integer") return {type: "number"}
53
+ if (normalizedType === "boolean") return {type: "boolean"}
54
+ if (normalizedType.startsWith("Array<") || normalizedType.endsWith("[]")) return {items: {type: "string"}, type: "array"}
55
+
56
+ return {type: "string"}
57
+ }
58
+
59
+ /**
60
+ * Builds the MCP tool name for one model command.
61
+ * @param {object} args - Arguments.
62
+ * @param {string} args.modelKey - Manifest resource key (configured model name).
63
+ * @param {string} args.commandName - Command method name.
64
+ * @param {"collection" | "member"} args.scope - Command scope.
65
+ * @returns {string} - MCP tool name.
66
+ */
67
+ function mcpToolName({commandName, modelKey, scope}) {
68
+ const modelPart = inflection.underscore(modelKey).toLowerCase()
69
+ const commandPart = inflection.underscore(commandName).toLowerCase()
70
+ const scopePart = scope === "member" ? "_by_id" : ""
71
+
72
+ return `${modelPart}_${commandPart}${scopePart}`
73
+ }
74
+
75
+ /**
76
+ * Builds the primary key input properties for one manifest resource.
77
+ * @param {string | string[] | undefined} primaryKey - Resource primary key attribute name(s).
78
+ * @returns {Record<string, {description: string, type: string}>} - Primary key JSON Schema properties.
79
+ */
80
+ function primaryKeyProperties(primaryKey) {
81
+ const keyAttributes = Array.isArray(primaryKey) ? primaryKey : [primaryKey || "id"]
82
+ /** @type {Record<string, {description: string, type: string}>} */
83
+ const properties = {}
84
+
85
+ for (const keyAttribute of keyAttributes) {
86
+ properties[keyAttribute] = {description: `Primary key attribute \`${keyAttribute}\` of the record.`, type: "string"}
87
+ }
88
+
89
+ return properties
90
+ }
91
+
92
+ /**
93
+ * Builds the fixed input schema for one built-in command.
94
+ * @param {object} args - Arguments.
95
+ * @param {string} args.commandName - Built-in command name.
96
+ * @param {string | string[] | undefined} args.primaryKey - Resource primary key attribute name(s).
97
+ * @returns {Record<string, unknown>} - JSON Schema object for the tool input.
98
+ */
99
+ function builtInCommandInputSchema({commandName, primaryKey}) {
100
+ if (commandName === "index") {
101
+ return {
102
+ additionalProperties: false,
103
+ properties: {
104
+ count: {description: "Return only the count of matching records.", type: "boolean"},
105
+ distinct: {description: "Apply DISTINCT to the index query.", type: "boolean"},
106
+ group: {description: "Group the index query by attribute names or relationship-path descriptors.", type: "object"},
107
+ joins: {description: "Relationship-path join descriptors.", type: "object"},
108
+ limit: {description: "Maximum number of records to return.", type: "number"},
109
+ offset: {description: "Number of records to skip.", type: "number"},
110
+ page: {description: "1-based page number (combine with `perPage`).", type: "number"},
111
+ perPage: {description: "Records per page.", type: "number"},
112
+ pluck: {description: "Attribute names or relationship-path descriptors to pluck instead of full records.", type: "array"},
113
+ preload: {description: "Relationship preload descriptors.", type: "object"},
114
+ queryData: {description: "Structured query-data descriptor.", type: "object"},
115
+ searches: {description: "Search descriptors (path/column/operator/value).", type: "array"},
116
+ select: {description: "Selected attribute names per model.", type: "object"},
117
+ selectsExtra: {description: "Extra selected attributes.", type: "object"},
118
+ sort: {description: "Sort definitions (column or relationship-path descriptors).", type: "array"},
119
+ where: {description: "Attribute filter hash.", type: "object"},
120
+ withCount: {description: "Include the total count alongside records.", type: "boolean"}
121
+ },
122
+ type: "object"
123
+ }
124
+ }
125
+
126
+ if (commandName === "create") {
127
+ return {
128
+ additionalProperties: false,
129
+ properties: {
130
+ attributes: {description: "Writable attribute values for the new record.", type: "object"},
131
+ nestedAttributes: {description: "Nested relationship attribute payloads.", type: "object"}
132
+ },
133
+ required: ["attributes"],
134
+ type: "object"
135
+ }
136
+ }
137
+
138
+ if (commandName === "update") {
139
+ const idProperties = primaryKeyProperties(primaryKey)
140
+
141
+ return {
142
+ additionalProperties: false,
143
+ properties: {
144
+ attributes: {description: "Writable attribute values to apply.", type: "object"},
145
+ ...idProperties,
146
+ nestedAttributes: {description: "Nested relationship attribute payloads.", type: "object"}
147
+ },
148
+ required: [...Object.keys(idProperties), "attributes"],
149
+ type: "object"
150
+ }
151
+ }
152
+
153
+ // find / destroy / attach / download / url / attachmentList are id-addressed
154
+ const idProperties = primaryKeyProperties(primaryKey)
155
+
156
+ return {
157
+ additionalProperties: false,
158
+ properties: {...idProperties},
159
+ required: [...Object.keys(idProperties)],
160
+ type: "object"
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Builds the input schema for one custom command from its manifest argument metadata.
166
+ * @param {object} args - Arguments.
167
+ * @param {ManifestCommandEntry} args.entry - Manifest command entry.
168
+ * @param {string | string[] | undefined} args.primaryKey - Resource primary key attribute name(s).
169
+ * @returns {Record<string, unknown>} - JSON Schema object for the tool input.
170
+ */
171
+ function customCommandInputSchema({entry, primaryKey}) {
172
+ const commandArgs = entry.args || []
173
+ /** @type {Record<string, {description?: string, items?: {items?: {type: string}, type: string}, type: string}>} */
174
+ const properties = {}
175
+
176
+ for (const commandArg of commandArgs) {
177
+ properties[commandArg.name] = jsonSchemaTypeForJSDocType(commandArg.type)
178
+ }
179
+
180
+ if (entry.scope === "member") {
181
+ for (const [keyAttribute, keySchema] of Object.entries(primaryKeyProperties(primaryKey))) {
182
+ if (!properties[keyAttribute]) properties[keyAttribute] = keySchema
183
+ }
184
+ }
185
+
186
+ return {
187
+ additionalProperties: true,
188
+ properties,
189
+ type: "object"
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Generates MCP tool definitions from a frontend-model API manifest.
195
+ * Every built-in and custom command of every manifest resource becomes one tool;
196
+ * one meta tool exposes the raw manifest for discovery.
197
+ * @param {ApiManifest} manifest - Manifest from `configuration.getApiManifest()`.
198
+ * @returns {Record<string, unknown>[]} - MCP `tools/list` tool definitions.
199
+ */
200
+ export function generateMcpTools(manifest) {
201
+ const resources = manifest.resources
202
+ const tools = []
203
+
204
+ if (!resources) return [buildMetaTool()]
205
+
206
+ for (const modelKey of Object.keys(resources).sort()) {
207
+ const resource = /** @type {ManifestResourceEntry} */ (resources[modelKey])
208
+ const commands = resource.commands || {}
209
+ const builtInCommands = resource.builtInCommands || {}
210
+
211
+ for (const scope of /** @type {Array<"collection" | "member">} */ (["collection", "member"])) {
212
+ for (const entry of scopeCommandEntries({builtInCommands, commands, scope})) {
213
+ const commandName = entry.methodName
214
+ const isBuiltIn = entry.isBuiltIn
215
+ const name = mcpToolName({commandName, modelKey, scope})
216
+
217
+ if (!MCP_TOOL_NAME_PATTERN.test(name)) continue
218
+
219
+ tools.push({
220
+ description: isBuiltIn
221
+ ? `${BUILT_IN_COMMAND_DESCRIPTIONS[commandName] || `Run the built-in \`${commandName}\` command on \`${resource.modelName}\`.`} Model: \`${resource.modelName}\`.`
222
+ : `Custom command \`${commandName}\` on model \`${resource.modelName}\`${entry.returnType ? ` (returns ${entry.returnType})` : ""}.`,
223
+ inputSchema: isBuiltIn
224
+ ? builtInCommandInputSchema({commandName, primaryKey: resource.primaryKey})
225
+ : customCommandInputSchema({entry, primaryKey: resource.primaryKey}),
226
+ name
227
+ })
228
+ }
229
+ }
230
+ }
231
+
232
+ tools.push(buildMetaTool())
233
+
234
+ return tools
235
+ }
236
+
237
+ /**
238
+ * Merges the manifest's built-in command names and custom command entries for
239
+ * one scope into a single deterministic command-entry list.
240
+ * @param {object} args - Arguments.
241
+ * @param {Record<string, Record<string, string>>} args.builtInCommands - Scope-keyed built-in command maps.
242
+ * @param {Record<string, ManifestCommandEntry[]>} args.commands - Scope-keyed custom command entries.
243
+ * @param {"collection" | "member"} args.scope - Command scope.
244
+ * @returns {Array<ManifestCommandEntry & {isBuiltIn: boolean}>} - Combined command entries.
245
+ */
246
+ function scopeCommandEntries({builtInCommands, commands, scope}) {
247
+ /** @type {Array<ManifestCommandEntry & {isBuiltIn: boolean}>} */
248
+ const entries = []
249
+
250
+ for (const commandName of Object.keys(builtInCommands[scope] || {})) {
251
+ entries.push({isBuiltIn: true, methodName: commandName, scope})
252
+ }
253
+
254
+ for (const entry of commands[scope] || []) {
255
+ entries.push({...entry, isBuiltIn: false})
256
+ }
257
+
258
+ return entries.sort((a, b) => a.methodName.localeCompare(b.methodName))
259
+ }
260
+
261
+ /**
262
+ * Builds the manifest discovery meta tool.
263
+ * @returns {Record<string, unknown>} - MCP tool definition.
264
+ */
265
+ function buildMetaTool() {
266
+ return {
267
+ description: META_TOOL_DESCRIPTION,
268
+ inputSchema: {additionalProperties: false, properties: {}, type: "object"},
269
+ name: META_TOOL_NAME
270
+ }
271
+ }
272
+
273
+ /**
274
+ * Parses one MCP tool name back into its manifest model key and command method name.
275
+ * @param {object} args - Arguments.
276
+ * @param {string} args.toolName - MCP tool name.
277
+ * @param {ApiManifest} args.manifest - Manifest from `configuration.getApiManifest()`.
278
+ * @returns {{commandName: string, modelKey: string, scope: "collection" | "member"} | null} - Command identity, or null when unknown.
279
+ */
280
+ export function parseMcpToolName({manifest, toolName}) {
281
+ if (toolName === META_TOOL_NAME) return null
282
+
283
+ const resources = manifest.resources
284
+
285
+ if (!resources) return null
286
+
287
+ for (const modelKey of Object.keys(resources)) {
288
+ const resource = /** @type {ManifestResourceEntry} */ (resources[modelKey])
289
+ const commands = resource.commands || {}
290
+ const builtInCommands = resource.builtInCommands || {}
291
+
292
+ for (const scope of /** @type {Array<"collection" | "member">} */ (["collection", "member"])) {
293
+ const commandNames = [...Object.keys(builtInCommands[scope] || {}), ...(commands[scope] || []).map((entry) => entry.methodName)]
294
+
295
+ for (const commandName of commandNames) {
296
+ if (mcpToolName({commandName, modelKey, scope}) === toolName) {
297
+ return {commandName, modelKey, scope}
298
+ }
299
+ }
300
+ }
301
+ }
302
+
303
+ return null
304
+ }
305
+
306
+ /**
307
+ * Runs the metaToolName helper.
308
+ * @returns {string} - The meta tool name.
309
+ */
310
+ export function metaToolName() { return META_TOOL_NAME }
@@ -0,0 +1,63 @@
1
+ import Controller from "../../../controller.js"
2
+ import {handleMcpRequest} from "../../../mcp/json-rpc-server.js"
3
+
4
+ export default class BuiltInMcpController extends Controller {
5
+ /**
6
+ * Runs one JSON-RPC message of the MCP protocol over the HTTP transport.
7
+ * The request body is parsed into params by the HTTP request parser; the
8
+ * JSON-RPC envelope is extracted from it and answered exactly like the stdio
9
+ * transport. The request's `Authorization` header is forwarded as the
10
+ * request-context `authorization` key, so tool calls authenticate through the
11
+ * app's own tenant/ability resolvers just like regular frontend-model calls.
12
+ * @returns {Promise<void>} - Resolves when the JSON-RPC response has been rendered.
13
+ */
14
+ async handle() {
15
+ const params = this.params()
16
+ const requestContext = buildRequestContextFromHeaders({request: this.request()})
17
+ const response = await handleMcpRequest({
18
+ configuration: this.getConfiguration(),
19
+ request: {
20
+ id: params.id ?? null,
21
+ jsonrpc: params.jsonrpc,
22
+ method: params.method,
23
+ params: typeof params.params === "object" && params.params !== null && !Array.isArray(params.params) ? params.params : undefined
24
+ },
25
+ requestContext
26
+ })
27
+
28
+ this._response.setHeader("Content-Type", "application/json; charset=UTF-8")
29
+
30
+ if (response === null) {
31
+ // Notifications never carry a JSON-RPC response id; 202 Accepted with an
32
+ // empty body keeps the HTTP transport honest about that.
33
+ this._response.setStatus(202)
34
+ this._response.setBody("")
35
+
36
+ return
37
+ }
38
+
39
+ this._response.setBody(`${JSON.stringify(response)}\n`)
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Builds the remote request context forwarded to every tool call from the
45
+ * incoming HTTP request headers. The `Authorization` header becomes the
46
+ * request-context `authorization` key, which the MCP dispatcher forwards as
47
+ * the Authorization header of the synthetic in-process frontend-model request.
48
+ * @param {object} args - Arguments.
49
+ * @param {import("../../../http-server/client/request.js").default} args.request - Incoming HTTP request.
50
+ * @returns {Record<string, string | number | boolean> | undefined} - Request context, or undefined when no headers carry context.
51
+ */
52
+ function buildRequestContextFromHeaders({request}) {
53
+ /** @type {Record<string, string | number | boolean>} */
54
+ const context = {}
55
+
56
+ const authorization = request.header("authorization")
57
+
58
+ if (typeof authorization === "string" && authorization.trim().length > 0) {
59
+ context.authorization = authorization.trim()
60
+ }
61
+
62
+ return Object.keys(context).length > 0 ? context : undefined
63
+ }
@@ -0,0 +1,58 @@
1
+ // @ts-check
2
+
3
+ import restArgsError from "../utils/rest-args-error.js"
4
+
5
+ /**
6
+ * Catch-all route options.
7
+ * @typedef {object} CatchAllRouteOptions
8
+ * @property {string} [controller] - Controller name unmatched requests resolve to.
9
+ * @property {string} [action] - Action name unmatched requests resolve to.
10
+ */
11
+
12
+ /**
13
+ * Application-declared catch-all route created with `route.catchAll(...)` in
14
+ * the routes file. It is not a path matcher: the resolver applies it only
15
+ * when no other route in the tree matched, replacing the built-in errors
16
+ * not-found response with the application's own controller/action (for
17
+ * example a JSON error envelope) without hand-wiring a route resolver hook.
18
+ */
19
+ export default class VelociousRouteCatchAllRoute {
20
+ /**
21
+ * Controller name unmatched requests resolve to.
22
+ * @type {string} */
23
+ controller
24
+
25
+ /**
26
+ * Action name unmatched requests resolve to.
27
+ * @type {string} */
28
+ action
29
+
30
+ /**
31
+ * Runs constructor.
32
+ * @param {CatchAllRouteOptions} [options] - Catch-all options.
33
+ */
34
+ constructor({controller = "_root", action = "notFound", ...restArgs} = {}) {
35
+ restArgsError(restArgs)
36
+
37
+ if (typeof controller !== "string" || controller.length < 1) {
38
+ throw new Error(`Expected catch-all 'controller' to be a non-empty string, got: ${String(controller)}`)
39
+ }
40
+
41
+ if (typeof action !== "string" || action.length < 1) {
42
+ throw new Error(`Expected catch-all 'action' to be a non-empty string, got: ${String(action)}`)
43
+ }
44
+
45
+ this.controller = controller
46
+ this.action = action
47
+ }
48
+
49
+ /**
50
+ * Runs get human paths.
51
+ * @returns {Array<{action: string | null, method: string, path: string}>} - Route definitions for this route.
52
+ */
53
+ getHumanPaths() {
54
+ return [
55
+ {method: "ANY", action: this.action, path: "*"}
56
+ ]
57
+ }
58
+ }
@@ -126,7 +126,10 @@ export default class VelociousRoutesResolver {
126
126
  const __filename = fileURLToPath(import.meta.url)
127
127
  const __dirname = dirname(__filename)
128
128
  const requestedPath = currentPath.replace(/^\//, "") || "_root"
129
- const attemptedControllerPath = `${this.configuration.getDirectory()}/src/routes/${requestedPath}/controller.js`
129
+ const catchAllRoute = currentRoute?.catchAllRoute
130
+ const attemptedControllerPath = catchAllRoute
131
+ ? `${this.configuration.getDirectory()}/src/routes/${catchAllRoute.controller}/controller.js`
132
+ : `${this.configuration.getDirectory()}/src/routes/${requestedPath}/controller.js`
130
133
 
131
134
  const logger = this.logger
132
135
 
@@ -136,13 +139,23 @@ export default class VelociousRoutesResolver {
136
139
 
137
140
  await logger.warn(`No route matched for ${loggedPath}. Tried controller at ${attemptedControllerPath}`)
138
141
 
139
- controller = "errors"
140
- controllerPath = "./built-in/errors/controller.js"
141
- action = "notFound"
142
- skipAbilityResolution = true
143
- skipControllerConnections = true
144
- skipTenantResolution = true
145
- viewPath = await fs.realpath(`${__dirname}/built-in/errors`)
142
+ if (catchAllRoute) {
143
+ controller = catchAllRoute.controller
144
+ action = normalizeActionName(catchAllRoute.action)
145
+ controllerPath = `${this.configuration.getDirectory()}/src/routes/${controller}/controller.js`
146
+ viewPath = `${this.configuration.getDirectory()}/src/routes/${controller}`
147
+ skipAbilityResolution = true
148
+ skipControllerConnections = true
149
+ skipTenantResolution = true
150
+ } else {
151
+ controller = "errors"
152
+ controllerPath = "./built-in/errors/controller.js"
153
+ action = "notFound"
154
+ skipAbilityResolution = true
155
+ skipControllerConnections = true
156
+ skipTenantResolution = true
157
+ viewPath = await fs.realpath(`${__dirname}/built-in/errors`)
158
+ }
146
159
  } else if (action) {
147
160
  if (!controller) controller = "_root"
148
161
 
@@ -1,11 +1,36 @@
1
1
  // @ts-check
2
2
 
3
3
  import BasicRoute from "./basic-route.js"
4
+ import CatchAllRoute from "./catch-all-route.js"
4
5
 
5
6
  export default class VelociousRootRoute extends BasicRoute {
7
+ /**
8
+ * Application catch-all route declared with `route.catchAll(...)`, or null
9
+ * when the application did not declare one.
10
+ * @type {CatchAllRoute | null} */
11
+ catchAllRoute = null
12
+
13
+ /**
14
+ * Runs catch all. Declares the application's catch-all route: unmatched
15
+ * requests resolve to the given controller/action instead of the built-in
16
+ * errors not-found response.
17
+ * @param {import("./catch-all-route.js").CatchAllRouteOptions} [options] - Catch-all options.
18
+ * @returns {void} - No return value.
19
+ */
20
+ catchAll(options = {}) {
21
+ if (this.catchAllRoute) throw new Error("Only one catchAll route can be declared")
22
+
23
+ this.catchAllRoute = new CatchAllRoute(options)
24
+ }
25
+
6
26
  getHumanPaths() {
7
- return [
27
+ /** @type {Array<{action: string | null, method: string, path: string}>} */
28
+ const humanPaths = [
8
29
  {method: "GET", action: "index", path: "/"}
9
30
  ]
31
+
32
+ if (this.catchAllRoute) humanPaths.push(...this.catchAllRoute.getHumanPaths())
33
+
34
+ return humanPaths
10
35
  }
11
36
  }