velocious 1.0.692 → 1.0.693
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 +2 -0
- package/build/configuration.js +16 -1
- 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/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 +10 -0
- package/build/src/configuration-types.d.ts.map +1 -1
- package/build/src/configuration-types.js +3 -1
- package/build/src/configuration.d.ts.map +1 -1
- package/build/src/configuration.js +13 -2
- 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/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 +2 -0
- package/src/configuration.js +16 -1
- 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/catch-all-route.js +58 -0
- package/src/routes/resolver.js +21 -8
- 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,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
|
+
}
|
package/src/routes/resolver.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
|
package/src/routes/root-route.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|