@baasix/baasix 0.1.26 → 0.1.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/routes/mcp.route.d.ts +2 -3
- package/dist/routes/mcp.route.d.ts.map +1 -1
- package/dist/routes/mcp.route.js +65 -47
- package/dist/routes/mcp.route.js.map +1 -1
- package/dist/services/MCPService.d.ts +28 -6
- package/dist/services/MCPService.d.ts.map +1 -1
- package/dist/services/MCPService.js +904 -702
- package/dist/services/MCPService.js.map +1 -1
- package/package.json +1 -1
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* MCPService - Model Context Protocol Server for Baasix
|
|
4
4
|
*
|
|
5
|
-
* This service provides MCP tools that
|
|
6
|
-
*
|
|
5
|
+
* This service provides MCP tools that call Baasix HTTP routes internally,
|
|
6
|
+
* ensuring all route-level validation, permission checks, and cache
|
|
7
|
+
* invalidation are applied consistently.
|
|
7
8
|
*
|
|
8
9
|
* Enable via environment variable: MCP_ENABLED=true
|
|
9
10
|
* Access at: http://localhost:8056/mcp (or custom MCP_PATH)
|
|
@@ -14,11 +15,6 @@
|
|
|
14
15
|
*/
|
|
15
16
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
16
17
|
import { z } from "zod";
|
|
17
|
-
import { schemaManager } from "../utils/schemaManager.js";
|
|
18
|
-
import { ItemsService } from "./ItemsService.js";
|
|
19
|
-
import permissionService from "./PermissionService.js";
|
|
20
|
-
import realtimeService from "./RealtimeService.js";
|
|
21
|
-
import settingsService from "./SettingsService.js";
|
|
22
18
|
import env from "../utils/env.js";
|
|
23
19
|
// ==================== Helper Functions ====================
|
|
24
20
|
// Session storage for authenticated MCP clients
|
|
@@ -41,9 +37,14 @@ function getAccountabilityFromSession(sessionId) {
|
|
|
41
37
|
function getDefaultAccountability() {
|
|
42
38
|
return {
|
|
43
39
|
user: null,
|
|
44
|
-
role:
|
|
45
|
-
|
|
46
|
-
|
|
40
|
+
role: {
|
|
41
|
+
id: "public",
|
|
42
|
+
name: "public",
|
|
43
|
+
isTenantSpecific: false,
|
|
44
|
+
},
|
|
45
|
+
permissions: [],
|
|
46
|
+
tenant: null,
|
|
47
|
+
ipaddress: "127.0.0.1",
|
|
47
48
|
};
|
|
48
49
|
}
|
|
49
50
|
/**
|
|
@@ -87,75 +88,147 @@ function errorResult(error) {
|
|
|
87
88
|
*/
|
|
88
89
|
export function createMCPServer() {
|
|
89
90
|
const server = new McpServer({
|
|
90
|
-
name: "
|
|
91
|
+
name: "Baasix Backend-as-a-Service",
|
|
91
92
|
version: "0.1.0",
|
|
93
|
+
instructions: `You are connected to a Baasix server — an open-source Backend-as-a-Service (BaaS) that provides a complete backend via API.
|
|
94
|
+
|
|
95
|
+
KEY CONCEPTS:
|
|
96
|
+
- "Collection" = a database table (e.g., "products", "orders", "users")
|
|
97
|
+
- "Schema" = the definition/structure of a table (its columns, types, constraints)
|
|
98
|
+
- "Item" = a row/record in a table
|
|
99
|
+
- "Relationship" = a foreign key or junction table between two collections
|
|
100
|
+
- System collections are prefixed with "baasix_" (e.g., baasix_User, baasix_Role) — avoid modifying these unless explicitly asked
|
|
101
|
+
|
|
102
|
+
COMMON TASK MAPPING:
|
|
103
|
+
- "Create a table" or "create a collection" → use baasix_create_schema (this creates both the schema definition AND the database table)
|
|
104
|
+
- "Add a column/field" → use baasix_update_schema
|
|
105
|
+
- "Insert/add data" or "create a record" → use baasix_create_item
|
|
106
|
+
- "Query/list/fetch data" or "get rows" → use baasix_list_items
|
|
107
|
+
- "Sum", "count", "average", "total", "report", "stats", "analytics", "dashboard", "min/max" → use baasix_generate_report (NOT baasix_list_items)
|
|
108
|
+
- "Stats across multiple tables" → use baasix_collection_stats
|
|
109
|
+
- "Link two tables" or "add a foreign key" → use baasix_create_relationship
|
|
110
|
+
- "Add an index" → use baasix_add_index
|
|
111
|
+
- "Set permissions" → use baasix_create_permission or baasix_update_permissions
|
|
112
|
+
- "Check what tables exist" → use baasix_list_schemas
|
|
113
|
+
|
|
114
|
+
WORKFLOW FOR CREATING A NEW TABLE:
|
|
115
|
+
1. baasix_create_schema — define the table name and fields
|
|
116
|
+
2. baasix_create_relationship — (optional) link it to other tables
|
|
117
|
+
3. baasix_add_index — (optional) add indexes for performance
|
|
118
|
+
4. baasix_create_permission — (optional) set role-based access
|
|
119
|
+
|
|
120
|
+
SCHEMA FIELD TYPES: String (VARCHAR), Text (unlimited), Integer, BigInt, Decimal (precision/scale), Float, Real, Double, Boolean, Date, DateTime, Time, UUID, SUID (short ID), JSONB, Array, Enum
|
|
121
|
+
|
|
122
|
+
DEFAULT VALUE TYPES: { type: "UUIDV4" }, { type: "SUID" }, { type: "NOW" }, { type: "AUTOINCREMENT" }, { type: "SQL", value: "..." }
|
|
123
|
+
|
|
124
|
+
EXAMPLE — Creating a "products" table:
|
|
125
|
+
Call baasix_create_schema with:
|
|
126
|
+
collection: "products"
|
|
127
|
+
schema: {
|
|
128
|
+
"timestamps": true,
|
|
129
|
+
"fields": {
|
|
130
|
+
"id": { "type": "UUID", "primaryKey": true, "defaultValue": { "type": "UUIDV4" } },
|
|
131
|
+
"name": { "type": "String", "allowNull": false, "values": { "length": 255 } },
|
|
132
|
+
"price": { "type": "Decimal", "values": { "precision": 10, "scale": 2 }, "defaultValue": 0 },
|
|
133
|
+
"inStock": { "type": "Boolean", "defaultValue": true }
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
`,
|
|
92
137
|
});
|
|
138
|
+
// ==================== Helper Functions ====================
|
|
139
|
+
/**
|
|
140
|
+
* Call a Baasix REST route with the MCP session's bearer token.
|
|
141
|
+
* This ensures all schema/relationship operations go through the same
|
|
142
|
+
* route logic (validation, processSchemaFlags, permission checks, cache invalidation)
|
|
143
|
+
* without duplicating code.
|
|
144
|
+
*/
|
|
145
|
+
async function callRoute(method, path, extra, body) {
|
|
146
|
+
const { default: axios } = await import("axios");
|
|
147
|
+
const baseUrl = `http://localhost:${env.get("PORT") || "8056"}`;
|
|
148
|
+
const accountability = getAccountability(extra);
|
|
149
|
+
const token = accountability.token;
|
|
150
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
151
|
+
if (token) {
|
|
152
|
+
headers['Authorization'] = `Bearer ${token}`;
|
|
153
|
+
}
|
|
154
|
+
try {
|
|
155
|
+
const response = await axios({
|
|
156
|
+
method,
|
|
157
|
+
url: `${baseUrl}${path}`,
|
|
158
|
+
headers,
|
|
159
|
+
data: body,
|
|
160
|
+
validateStatus: () => true, // don't throw on non-2xx
|
|
161
|
+
});
|
|
162
|
+
if (response.status >= 200 && response.status < 300) {
|
|
163
|
+
return { ok: true, data: response.data, status: response.status };
|
|
164
|
+
}
|
|
165
|
+
const msg = response.data?.message || response.data?.error || JSON.stringify(response.data);
|
|
166
|
+
return { ok: false, error: msg, status: response.status };
|
|
167
|
+
}
|
|
168
|
+
catch (err) {
|
|
169
|
+
return { ok: false, error: err.message || 'Route call failed' };
|
|
170
|
+
}
|
|
171
|
+
}
|
|
93
172
|
// ==================== Schema Management Tools ====================
|
|
94
|
-
|
|
173
|
+
// All schema tools call the REST routes via HTTP with the session's bearer token.
|
|
174
|
+
// This ensures route-level validation, processSchemaFlags, permission checks and cache
|
|
175
|
+
// invalidation are applied exactly once (in the route) — zero duplicated logic.
|
|
176
|
+
server.tool("baasix_list_schemas", "List all database tables (collections) and their schema definitions. Use this to discover what tables exist. System tables are prefixed with 'baasix_'.", {
|
|
95
177
|
search: z.string().optional().describe("Search term to filter schemas by collection name"),
|
|
96
178
|
page: z.number().optional().default(1).describe("Page number for pagination"),
|
|
97
179
|
limit: z.number().optional().default(10).describe("Number of schemas per page"),
|
|
98
180
|
sort: z.string().optional().default("collectionName:asc").describe("Sort field and direction"),
|
|
99
181
|
}, async (args, extra) => {
|
|
100
182
|
const { search, page, limit, sort } = args;
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
page,
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
if (sort
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
}
|
|
112
|
-
if (
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
}
|
|
116
|
-
const result = await schemaService.readByQuery(query, true);
|
|
117
|
-
return successResult({
|
|
118
|
-
data: result.data,
|
|
119
|
-
totalCount: result.totalCount,
|
|
120
|
-
page,
|
|
121
|
-
limit,
|
|
122
|
-
});
|
|
183
|
+
const params = new URLSearchParams();
|
|
184
|
+
if (search)
|
|
185
|
+
params.set('search', search);
|
|
186
|
+
if (page)
|
|
187
|
+
params.set('page', String(page));
|
|
188
|
+
if (limit)
|
|
189
|
+
params.set('limit', String(limit));
|
|
190
|
+
if (sort)
|
|
191
|
+
params.set('sort', sort);
|
|
192
|
+
const qs = params.toString();
|
|
193
|
+
const res = await callRoute('GET', `/schemas${qs ? '?' + qs : ''}`, extra);
|
|
194
|
+
if (!res.ok)
|
|
195
|
+
return errorResult(res.error || 'Failed to list schemas');
|
|
196
|
+
return successResult(res.data);
|
|
123
197
|
});
|
|
124
|
-
server.tool("baasix_get_schema", "Get
|
|
198
|
+
server.tool("baasix_get_schema", "Get the full schema definition (columns, types, constraints, relationships) for a specific database table/collection.", {
|
|
125
199
|
collection: z.string().describe("Collection name"),
|
|
126
|
-
}, async (args,
|
|
200
|
+
}, async (args, extra) => {
|
|
127
201
|
const { collection } = args;
|
|
128
|
-
const
|
|
129
|
-
if (!
|
|
130
|
-
return errorResult(`Schema '${collection}' not found`);
|
|
131
|
-
|
|
132
|
-
return successResult({
|
|
133
|
-
collectionName: collection,
|
|
134
|
-
schema: schemaDef,
|
|
135
|
-
});
|
|
202
|
+
const res = await callRoute('GET', `/schemas/${encodeURIComponent(collection)}`, extra);
|
|
203
|
+
if (!res.ok)
|
|
204
|
+
return errorResult(res.error || `Schema '${collection}' not found`);
|
|
205
|
+
return successResult(res.data);
|
|
136
206
|
});
|
|
137
|
-
server.tool("baasix_create_schema", `
|
|
207
|
+
server.tool("baasix_create_schema", `CREATE A NEW DATABASE TABLE. This is the tool to use when asked to "create a table", "create a collection", "add a new model", or "define a new entity".
|
|
208
|
+
|
|
209
|
+
This creates both the schema definition AND the actual PostgreSQL table with all specified columns.
|
|
138
210
|
|
|
139
211
|
FIELD TYPES:
|
|
140
|
-
- String: VARCHAR
|
|
141
|
-
- Text: Unlimited text
|
|
212
|
+
- String: VARCHAR — requires values.length (e.g., { "type": "String", "values": { "length": 255 } })
|
|
213
|
+
- Text: Unlimited length text
|
|
142
214
|
- Integer, BigInt: Whole numbers
|
|
143
|
-
- Decimal: values.precision & values.scale
|
|
144
|
-
- Float, Real, Double: Floating point
|
|
215
|
+
- Decimal: requires values.precision & values.scale (e.g., { "type": "Decimal", "values": { "precision": 10, "scale": 2 } })
|
|
216
|
+
- Float, Real, Double: Floating point numbers
|
|
145
217
|
- Boolean: true/false
|
|
146
|
-
- Date, DateTime, Time: Date/time
|
|
147
|
-
- UUID:
|
|
148
|
-
- SUID: Short unique ID with defaultValue
|
|
149
|
-
- JSONB: JSON with indexing
|
|
150
|
-
- Array: values.type
|
|
151
|
-
- Enum: values.values
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
218
|
+
- Date, DateTime, Time: Date/time values
|
|
219
|
+
- UUID: Use with defaultValue { "type": "UUIDV4" } for auto-generated IDs
|
|
220
|
+
- SUID: Short unique ID with defaultValue { "type": "SUID" }
|
|
221
|
+
- JSONB: JSON data with indexing support
|
|
222
|
+
- Array: Specify element type via values.type (e.g., { "type": "Array", "values": { "type": "String" } })
|
|
223
|
+
- Enum: Specify allowed values via values.values (e.g., { "type": "Enum", "values": { "values": ["active", "inactive"] } })
|
|
224
|
+
|
|
225
|
+
FIELD OPTIONS: allowNull (boolean), unique (boolean), primaryKey (boolean), defaultValue (value or { type: "UUIDV4"|"SUID"|"NOW"|"AUTOINCREMENT" })
|
|
226
|
+
|
|
227
|
+
ALWAYS include an "id" field as primary key. Add "timestamps": true in the schema for automatic createdAt/updatedAt. Add "paranoid": true for soft deletes (deletedAt).
|
|
228
|
+
|
|
229
|
+
EXAMPLE — Create a "products" table:
|
|
230
|
+
collection: "products"
|
|
231
|
+
schema: { "timestamps": true, "fields": { "id": { "type": "UUID", "primaryKey": true, "defaultValue": { "type": "UUIDV4" } }, "name": { "type": "String", "allowNull": false, "values": { "length": 255 } }, "price": { "type": "Decimal", "values": { "precision": 10, "scale": 2 }, "defaultValue": 0 }, "inStock": { "type": "Boolean", "defaultValue": true } } }`, {
|
|
159
232
|
collection: z.string().describe("Collection name"),
|
|
160
233
|
schema: z
|
|
161
234
|
.object({
|
|
@@ -163,21 +236,18 @@ DEFAULT VALUE TYPES:
|
|
|
163
236
|
})
|
|
164
237
|
.passthrough()
|
|
165
238
|
.describe("Schema definition"),
|
|
166
|
-
}, async (args,
|
|
239
|
+
}, async (args, extra) => {
|
|
167
240
|
const { collection, schema } = args;
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
return
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
}
|
|
176
|
-
catch (error) {
|
|
177
|
-
return errorResult(error);
|
|
178
|
-
}
|
|
241
|
+
const res = await callRoute('POST', '/schemas', extra, { collectionName: collection, schema });
|
|
242
|
+
if (!res.ok)
|
|
243
|
+
return errorResult(res.error || `Failed to create collection '${collection}'`);
|
|
244
|
+
return successResult({
|
|
245
|
+
success: true,
|
|
246
|
+
message: `Collection '${collection}' created successfully`,
|
|
247
|
+
collectionName: collection,
|
|
248
|
+
});
|
|
179
249
|
});
|
|
180
|
-
server.tool("baasix_update_schema", "
|
|
250
|
+
server.tool("baasix_update_schema", "Modify an existing database table — add new columns, change column types, or remove columns. Use this when asked to 'add a field', 'add a column', or 'alter a table'.", {
|
|
181
251
|
collection: z.string().describe("Collection name"),
|
|
182
252
|
schema: z
|
|
183
253
|
.object({
|
|
@@ -185,35 +255,23 @@ DEFAULT VALUE TYPES:
|
|
|
185
255
|
})
|
|
186
256
|
.passthrough()
|
|
187
257
|
.describe("Schema updates"),
|
|
188
|
-
}, async (args,
|
|
258
|
+
}, async (args, extra) => {
|
|
189
259
|
const { collection, schema } = args;
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
return
|
|
193
|
-
|
|
194
|
-
message: `Collection '${collection}' updated successfully`,
|
|
195
|
-
});
|
|
196
|
-
}
|
|
197
|
-
catch (error) {
|
|
198
|
-
return errorResult(error);
|
|
199
|
-
}
|
|
260
|
+
const res = await callRoute('PATCH', `/schemas/${encodeURIComponent(collection)}`, extra, { schema });
|
|
261
|
+
if (!res.ok)
|
|
262
|
+
return errorResult(res.error || `Failed to update collection '${collection}'`);
|
|
263
|
+
return successResult({ success: true, message: `Collection '${collection}' updated successfully` });
|
|
200
264
|
});
|
|
201
|
-
server.tool("baasix_delete_schema", "
|
|
265
|
+
server.tool("baasix_delete_schema", "DROP/DELETE an entire database table and all its data permanently. Use this when asked to 'drop a table', 'delete a collection', or 'remove a table'.", {
|
|
202
266
|
collection: z.string().describe("Collection name to delete"),
|
|
203
|
-
}, async (args,
|
|
267
|
+
}, async (args, extra) => {
|
|
204
268
|
const { collection } = args;
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
return
|
|
208
|
-
|
|
209
|
-
message: `Collection '${collection}' deleted successfully`,
|
|
210
|
-
});
|
|
211
|
-
}
|
|
212
|
-
catch (error) {
|
|
213
|
-
return errorResult(error);
|
|
214
|
-
}
|
|
269
|
+
const res = await callRoute('DELETE', `/schemas/${encodeURIComponent(collection)}`, extra);
|
|
270
|
+
if (!res.ok)
|
|
271
|
+
return errorResult(res.error || `Failed to delete collection '${collection}'`);
|
|
272
|
+
return successResult({ success: true, message: `Collection '${collection}' deleted successfully` });
|
|
215
273
|
});
|
|
216
|
-
server.tool("baasix_add_index", "Add
|
|
274
|
+
server.tool("baasix_add_index", "Add a database index to a table for better query performance. Supports btree, hash, gin, and gist index types. Can be unique.", {
|
|
217
275
|
collection: z.string().describe("Collection name"),
|
|
218
276
|
indexDefinition: z
|
|
219
277
|
.object({
|
|
@@ -223,52 +281,43 @@ DEFAULT VALUE TYPES:
|
|
|
223
281
|
type: z.enum(["btree", "hash", "gin", "gist"]).optional().describe("Index type"),
|
|
224
282
|
})
|
|
225
283
|
.describe("Index definition"),
|
|
226
|
-
}, async (args,
|
|
284
|
+
}, async (args, extra) => {
|
|
227
285
|
const { collection, indexDefinition } = args;
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
return
|
|
231
|
-
|
|
232
|
-
message: `Index added to '${collection}' successfully`,
|
|
233
|
-
});
|
|
234
|
-
}
|
|
235
|
-
catch (error) {
|
|
236
|
-
return errorResult(error);
|
|
237
|
-
}
|
|
286
|
+
const res = await callRoute('POST', `/schemas/${encodeURIComponent(collection)}/indexes`, extra, indexDefinition);
|
|
287
|
+
if (!res.ok)
|
|
288
|
+
return errorResult(res.error || `Failed to add index to '${collection}'`);
|
|
289
|
+
return successResult({ success: true, message: `Index added to '${collection}' successfully` });
|
|
238
290
|
});
|
|
239
|
-
server.tool("baasix_remove_index", "Remove
|
|
291
|
+
server.tool("baasix_remove_index", "Remove a database index from a table.", {
|
|
240
292
|
collection: z.string().describe("Collection name"),
|
|
241
293
|
indexName: z.string().describe("Name of the index to remove"),
|
|
242
|
-
}, async (args,
|
|
294
|
+
}, async (args, extra) => {
|
|
243
295
|
const { collection, indexName } = args;
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
return
|
|
247
|
-
|
|
248
|
-
message: `Index '${indexName}' removed from '${collection}'`,
|
|
249
|
-
});
|
|
250
|
-
}
|
|
251
|
-
catch (error) {
|
|
252
|
-
return errorResult(error);
|
|
253
|
-
}
|
|
296
|
+
const res = await callRoute('DELETE', `/schemas/${encodeURIComponent(collection)}/indexes/${encodeURIComponent(indexName)}`, extra);
|
|
297
|
+
if (!res.ok)
|
|
298
|
+
return errorResult(res.error || `Failed to remove index '${indexName}' from '${collection}'`);
|
|
299
|
+
return successResult({ success: true, message: `Index '${indexName}' removed from '${collection}'` });
|
|
254
300
|
});
|
|
255
|
-
server.tool("baasix_create_relationship", `Create a relationship between collections.
|
|
301
|
+
server.tool("baasix_create_relationship", `Create a foreign key / relationship between two database tables. Use this when asked to 'link tables', 'add a foreign key', 'create a relation', or 'connect collections'.
|
|
256
302
|
|
|
257
303
|
RELATIONSHIP TYPES:
|
|
258
|
-
- M2O (Many-to-One):
|
|
259
|
-
- O2M (One-to-Many): Virtual reverse of M2O. categories.products → products
|
|
260
|
-
- O2O (One-to-One):
|
|
261
|
-
- M2M (Many-to-Many):
|
|
262
|
-
- M2A (Many-to-Any): Polymorphic
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
304
|
+
- M2O (Many-to-One / BelongsTo): Adds a foreign key column. Example: products.category_Id → categories.id
|
|
305
|
+
- O2M (One-to-Many / HasMany): Virtual reverse of M2O. Example: categories.products → products
|
|
306
|
+
- O2O (One-to-One / HasOne): Unique foreign key. Example: users.profile_Id → profiles.id
|
|
307
|
+
- M2M (Many-to-Many): Auto-creates a junction table. Example: products ↔ tags
|
|
308
|
+
- M2A (Many-to-Any): Polymorphic — one source to multiple target tables
|
|
309
|
+
|
|
310
|
+
PARAMETERS:
|
|
311
|
+
- sourceCollection: the table that gets the foreign key (e.g., "products")
|
|
312
|
+
- name: the field name for the FK (e.g., "category" creates "category_Id" column)
|
|
313
|
+
- type: relationship type (M2O, O2M, O2O, M2M, M2A)
|
|
314
|
+
- target: the related table (e.g., "categories")
|
|
315
|
+
- alias: reverse-access name on the target table (e.g., "products" so categories.products works)
|
|
316
|
+
- onDelete: CASCADE | RESTRICT | SET NULL
|
|
317
|
+
|
|
318
|
+
EXAMPLE — products belongsTo categories:
|
|
319
|
+
sourceCollection: "products"
|
|
320
|
+
relationshipData: { "name": "category", "type": "M2O", "target": "categories", "alias": "products", "onDelete": "CASCADE" }`, {
|
|
272
321
|
sourceCollection: z.string().describe("Source collection name"),
|
|
273
322
|
relationshipData: z
|
|
274
323
|
.object({
|
|
@@ -282,188 +331,269 @@ EXAMPLE M2O:
|
|
|
282
331
|
through: z.string().optional().describe("Custom junction table name for M2M/M2A"),
|
|
283
332
|
})
|
|
284
333
|
.describe("Relationship configuration"),
|
|
285
|
-
}, async (args,
|
|
334
|
+
}, async (args, extra) => {
|
|
286
335
|
const { sourceCollection, relationshipData } = args;
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
return
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
catch (error) {
|
|
295
|
-
return errorResult(error);
|
|
296
|
-
}
|
|
336
|
+
const res = await callRoute('POST', `/schemas/${encodeURIComponent(sourceCollection)}/relationships`, extra, relationshipData);
|
|
337
|
+
if (!res.ok)
|
|
338
|
+
return errorResult(res.error || `Failed to create relationship '${relationshipData.name}' on '${sourceCollection}'`);
|
|
339
|
+
return successResult({
|
|
340
|
+
success: true,
|
|
341
|
+
message: `Relationship '${relationshipData.name}' created on '${sourceCollection}'`,
|
|
342
|
+
});
|
|
297
343
|
});
|
|
298
|
-
server.tool("baasix_delete_relationship", "
|
|
344
|
+
server.tool("baasix_delete_relationship", "Remove a foreign key / relationship from a database table.", {
|
|
299
345
|
sourceCollection: z.string().describe("Source collection name"),
|
|
300
346
|
fieldName: z.string().describe("Relationship field name"),
|
|
301
|
-
}, async (args,
|
|
347
|
+
}, async (args, extra) => {
|
|
302
348
|
const { sourceCollection, fieldName } = args;
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
return
|
|
306
|
-
|
|
307
|
-
message: `Relationship '${fieldName}' removed from '${sourceCollection}'`,
|
|
308
|
-
});
|
|
309
|
-
}
|
|
310
|
-
catch (error) {
|
|
311
|
-
return errorResult(error);
|
|
312
|
-
}
|
|
349
|
+
const res = await callRoute('DELETE', `/schemas/${encodeURIComponent(sourceCollection)}/relationships/${encodeURIComponent(fieldName)}`, extra);
|
|
350
|
+
if (!res.ok)
|
|
351
|
+
return errorResult(res.error || `Failed to remove relationship '${fieldName}' from '${sourceCollection}'`);
|
|
352
|
+
return successResult({ success: true, message: `Relationship '${fieldName}' removed from '${sourceCollection}'` });
|
|
313
353
|
});
|
|
314
|
-
server.tool("baasix_export_schemas", "Export all
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
return
|
|
318
|
-
|
|
319
|
-
catch (error) {
|
|
320
|
-
return errorResult(error);
|
|
321
|
-
}
|
|
354
|
+
server.tool("baasix_export_schemas", "Export all table definitions as JSON for backup or migration.", {}, async (_args, extra) => {
|
|
355
|
+
const res = await callRoute('GET', '/schemas-export', extra);
|
|
356
|
+
if (!res.ok)
|
|
357
|
+
return errorResult(res.error || 'Failed to export schemas');
|
|
358
|
+
return successResult(res.data);
|
|
322
359
|
});
|
|
323
|
-
server.tool("baasix_import_schemas", "Import
|
|
360
|
+
server.tool("baasix_import_schemas", "Import table definitions from JSON data to recreate tables.", {
|
|
324
361
|
schemas: z.record(z.any()).describe("Schema data to import"),
|
|
325
|
-
}, async (args,
|
|
362
|
+
}, async (args, extra) => {
|
|
326
363
|
const { schemas } = args;
|
|
364
|
+
// The import route expects a file upload (multipart/form-data).
|
|
365
|
+
// Use native Node.js FormData + Blob (available in Node 18+).
|
|
366
|
+
const { default: axios } = await import("axios");
|
|
367
|
+
const baseUrl = `http://localhost:${env.get("PORT") || "8056"}`;
|
|
368
|
+
const accountability = getAccountability(extra);
|
|
369
|
+
const token = accountability.token;
|
|
370
|
+
const jsonStr = JSON.stringify(schemas);
|
|
371
|
+
const blob = new Blob([jsonStr], { type: 'application/json' });
|
|
372
|
+
const form = new FormData();
|
|
373
|
+
form.append('schema', blob, 'schema.json');
|
|
374
|
+
const headers = {};
|
|
375
|
+
if (token) {
|
|
376
|
+
headers['Authorization'] = `Bearer ${token}`;
|
|
377
|
+
}
|
|
327
378
|
try {
|
|
328
|
-
await
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
message: "Schemas imported successfully",
|
|
379
|
+
const response = await axios.post(`${baseUrl}/schemas-import`, form, {
|
|
380
|
+
headers,
|
|
381
|
+
validateStatus: () => true,
|
|
332
382
|
});
|
|
383
|
+
if (response.status >= 200 && response.status < 300) {
|
|
384
|
+
return successResult(response.data);
|
|
385
|
+
}
|
|
386
|
+
const msg = response.data?.message || response.data?.error || JSON.stringify(response.data);
|
|
387
|
+
return errorResult(msg);
|
|
333
388
|
}
|
|
334
|
-
catch (
|
|
335
|
-
return errorResult(
|
|
389
|
+
catch (err) {
|
|
390
|
+
return errorResult(err.message || 'Failed to import schemas');
|
|
336
391
|
}
|
|
337
392
|
});
|
|
338
393
|
// ==================== Item Management Tools ====================
|
|
339
|
-
server.tool("baasix_list_items", `Query
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
-
|
|
346
|
-
-
|
|
347
|
-
-
|
|
348
|
-
-
|
|
349
|
-
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
394
|
+
server.tool("baasix_list_items", `Query/list/fetch rows from a database table. Use this when asked to 'get data', 'list records', 'query items', 'search', or 'fetch rows' from any table.
|
|
395
|
+
|
|
396
|
+
NOTE: For analytics, summaries, totals, sums, averages, counts, min/max, grouped reports, or dashboards → use baasix_generate_report instead. Use this tool (list_items) for fetching actual row data.
|
|
397
|
+
|
|
398
|
+
--- FIELDS (selecting columns & related data) ---
|
|
399
|
+
Use dot notation to include related table data. Wildcards expand to all columns.
|
|
400
|
+
- ["*"] → all columns on the main table
|
|
401
|
+
- ["name", "price"] → only specific columns
|
|
402
|
+
- ["*", "category.*"] → all main columns + all columns from the related "category" table
|
|
403
|
+
- ["name", "category.name", "category.id"] → specific columns from main + related table
|
|
404
|
+
- ["*", "author.profile.*"] → deep nested relations (up to 7 levels)
|
|
405
|
+
The primary key column is always returned even if not listed.
|
|
406
|
+
|
|
407
|
+
--- FILTER OPERATORS (case-sensitive, use exactly as shown) ---
|
|
408
|
+
Comparison: eq, ne, gt, gte, lt, lte
|
|
409
|
+
String: like, notLike, iLike, notILike, startsWith, endsWith, nstartsWith, nendsWith
|
|
410
|
+
Null check: isNull (true/false), isNotNull (true/false)
|
|
411
|
+
List: in, notIn, between, notBetween
|
|
412
|
+
Misc: not, is
|
|
413
|
+
Array (PostgreSQL arrays): arraycontains, arraycontained
|
|
414
|
+
JSONB: jsonbContains, jsonbContainedBy, jsonbHasKey, jsonbHasAnyKeys, jsonbHasAllKeys, jsonbPathExists, jsonbPathMatch, jsonbNotContains, jsonbKeyEquals, jsonbKeyNotEquals, jsonbKeyGt, jsonbKeyGte, jsonbKeyLt, jsonbKeyLte, jsonbKeyIn, jsonbKeyNotIn, jsonbKeyLike, jsonbKeyIsNull, jsonbKeyIsNotNull, jsonbArrayLength, jsonbTypeOf, jsonbDeepValue
|
|
415
|
+
Geospatial: dwithin, intersects, nIntersects, within, containsGEO
|
|
416
|
+
Logical (combine conditions): AND, OR
|
|
417
|
+
|
|
418
|
+
Type casting in filters — add "cast" to force column type:
|
|
419
|
+
{"price": {"cast": "numeric", "gt": 100}}
|
|
420
|
+
{"createdAt": {"cast": "date", "gte": "2024-01-01"}}
|
|
421
|
+
Valid cast types: text, varchar, integer, int, bigint, numeric, decimal, real, float, double precision, boolean, date, timestamp, timestamptz, time, uuid, json, jsonb, text[], varchar[], integer[], bigint[], uuid[]
|
|
422
|
+
|
|
423
|
+
--- DYNAMIC VARIABLES (use as string values in filters) ---
|
|
424
|
+
$CURRENT_USER → current authenticated user's ID
|
|
425
|
+
$CURRENT_USER.fieldName → any field on current user (e.g., $CURRENT_USER.tenant_Id)
|
|
426
|
+
$CURRENT_ROLE → current role ID
|
|
427
|
+
$CURRENT_ROLE.fieldName → any field on current role
|
|
428
|
+
$NOW → current ISO timestamp
|
|
429
|
+
$NOW+DAYS_7 → 7 days from now, $NOW-DAYS_7 → 7 days ago
|
|
430
|
+
Pattern: $NOW[+/-](YEARS|MONTHS|WEEKS|DAYS|HOURS|MINUTES|SECONDS)_N
|
|
431
|
+
|
|
432
|
+
--- SORT ---
|
|
433
|
+
Format: "fieldName:asc" or "fieldName:desc"
|
|
434
|
+
Relation sort: "category.name:asc" (sort by related table field)
|
|
435
|
+
|
|
436
|
+
--- SEARCH (PostgreSQL full-text search) ---
|
|
437
|
+
The search parameter performs full-text search with prefix matching.
|
|
438
|
+
By default searches all text/varchar/uuid columns. Use searchFields to limit which columns are searched.
|
|
439
|
+
|
|
440
|
+
--- AGGREGATE (SQL aggregation functions) ---
|
|
441
|
+
Format: {"aliasName": {"function": "functionName", "field": "columnName"}}
|
|
442
|
+
Functions: count, countDistinct, sum, avg, min, max, array_agg
|
|
443
|
+
Examples:
|
|
444
|
+
{"total": {"function": "count", "field": "*"}} → count all rows
|
|
445
|
+
{"avgPrice": {"function": "avg", "field": "price"}} → average price
|
|
446
|
+
{"uniqueCategories": {"function": "countDistinct", "field": "category"}} → count distinct
|
|
447
|
+
Relation aggregation: {"orderTotal": {"function": "sum", "field": "orders.total"}} → sum via join
|
|
448
|
+
|
|
449
|
+
--- GROUPBY ---
|
|
450
|
+
Format: array of field names. Supports date extraction: ["date:year:createdAt", "status"]
|
|
451
|
+
Date parts: year, month, day, hour. Relation paths: ["category.name"]
|
|
452
|
+
|
|
453
|
+
--- RELCONDITIONS (filter on related/joined tables) ---
|
|
454
|
+
Filter rows based on conditions on their related records. Keys are relation names, values are filter objects.
|
|
455
|
+
{"category": {"status": {"eq": "active"}}} → only rows whose related category has status=active
|
|
456
|
+
{"author": {"verified": {"eq": true}}} → only rows whose author is verified
|
|
457
|
+
Nested: {"author": {"department": {"name": {"eq": "Engineering"}}}} → filter by author's department name
|
|
458
|
+
Supports same operators and dynamic variables as filter.
|
|
459
|
+
|
|
460
|
+
--- FILTER EXAMPLES ---
|
|
461
|
+
{"status": {"eq": "active"}} → exact match
|
|
462
|
+
{"status": {"ne": "archived"}} → not equal
|
|
463
|
+
{"AND": [{"price": {"gte": 10}}, {"price": {"lte": 100}}]} → range
|
|
464
|
+
{"OR": [{"status": {"eq": "active"}}, {"status": {"eq": "pending"}}]} → either condition
|
|
465
|
+
{"name": {"iLike": "%john%"}} → case-insensitive pattern
|
|
466
|
+
{"email": {"startsWith": "admin"}} → prefix match
|
|
467
|
+
{"deletedAt": {"isNull": true}} → null check
|
|
468
|
+
{"category": {"in": ["books", "electronics"]}} → list membership
|
|
469
|
+
{"tags": {"arraycontains": ["featured"]}} → PostgreSQL array contains
|
|
470
|
+
{"metadata": {"jsonbHasKey": "color"}} → JSONB key exists
|
|
471
|
+
{"age": {"between": [18, 65]}} → between range
|
|
472
|
+
{"userId": {"eq": "$CURRENT_USER"}} → current user's records
|
|
473
|
+
{"createdAt": {"gte": "$NOW-DAYS_30"}} → last 30 days`, {
|
|
474
|
+
collection: z.string().describe("Table/collection name to query"),
|
|
475
|
+
filter: z.record(z.any()).optional().describe("Filter object using operators like {field: {operator: value}}. Combine with AND/OR for complex queries."),
|
|
476
|
+
fields: z.array(z.string()).optional().describe("Columns to return. Use [\"*\"] for all, dot notation for relations: [\"*\", \"category.*\"]"),
|
|
477
|
+
sort: z.string().optional().describe("Sort as 'field:asc' or 'field:desc'. Supports relation paths like 'category.name:asc'"),
|
|
478
|
+
page: z.number().optional().default(1).describe("Page number for pagination (starts at 1)"),
|
|
479
|
+
limit: z.number().optional().default(10).describe("Rows per page. Use -1 to fetch ALL rows (no pagination)"),
|
|
480
|
+
search: z.string().optional().describe("Full-text search query string. Searches text/varchar columns by default."),
|
|
481
|
+
searchFields: z.array(z.string()).optional().describe("Limit search to specific columns: [\"name\", \"description\"]"),
|
|
482
|
+
aggregate: z.record(z.any()).optional().describe("Aggregation: {alias: {function: 'count|sum|avg|min|max|countDistinct|array_agg', field: 'columnName'}}"),
|
|
483
|
+
groupBy: z.array(z.string()).optional().describe("Group results by fields: [\"status\"] or date extraction [\"date:year:createdAt\"]"),
|
|
484
|
+
relConditions: z.record(z.any()).optional().describe("Filter by related table data: {relationName: {field: {operator: value}}}"),
|
|
372
485
|
}, async (args, extra) => {
|
|
373
486
|
const { collection, filter, fields, sort, page, limit, search, searchFields, aggregate, groupBy, relConditions } = args;
|
|
374
487
|
try {
|
|
375
|
-
const
|
|
376
|
-
|
|
377
|
-
|
|
488
|
+
const params = new URLSearchParams();
|
|
489
|
+
if (page)
|
|
490
|
+
params.set('page', String(page));
|
|
491
|
+
if (limit)
|
|
492
|
+
params.set('limit', String(limit));
|
|
378
493
|
if (filter)
|
|
379
|
-
|
|
494
|
+
params.set('filter', JSON.stringify(filter));
|
|
380
495
|
if (fields)
|
|
381
|
-
|
|
496
|
+
params.set('fields', JSON.stringify(fields));
|
|
382
497
|
if (sort) {
|
|
383
498
|
const [field, direction] = sort.split(":");
|
|
384
|
-
|
|
499
|
+
params.set('sort', JSON.stringify([direction?.toLowerCase() === "desc" ? `-${field}` : field]));
|
|
385
500
|
}
|
|
386
501
|
if (search)
|
|
387
|
-
|
|
502
|
+
params.set('search', search);
|
|
388
503
|
if (searchFields)
|
|
389
|
-
|
|
504
|
+
params.set('searchFields', searchFields.join(','));
|
|
390
505
|
if (aggregate)
|
|
391
|
-
|
|
506
|
+
params.set('aggregate', JSON.stringify(aggregate));
|
|
392
507
|
if (groupBy)
|
|
393
|
-
|
|
508
|
+
params.set('groupBy', groupBy.join(','));
|
|
394
509
|
if (relConditions)
|
|
395
|
-
|
|
396
|
-
const
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
limit,
|
|
402
|
-
});
|
|
510
|
+
params.set('relConditions', JSON.stringify(relConditions));
|
|
511
|
+
const qs = params.toString();
|
|
512
|
+
const res = await callRoute('GET', `/items/${encodeURIComponent(collection)}${qs ? '?' + qs : ''}`, extra);
|
|
513
|
+
if (!res.ok)
|
|
514
|
+
return errorResult(res.error || 'Failed to list items');
|
|
515
|
+
return successResult(res.data);
|
|
403
516
|
}
|
|
404
517
|
catch (error) {
|
|
405
518
|
return errorResult(error);
|
|
406
519
|
}
|
|
407
520
|
});
|
|
408
|
-
server.tool("baasix_get_item",
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
521
|
+
server.tool("baasix_get_item", `Get a single row/record by its ID from a database table. Optionally include related data from linked tables.
|
|
522
|
+
|
|
523
|
+
FIELDS — use dot notation to include related data:
|
|
524
|
+
- ["*"] → all columns on this table
|
|
525
|
+
- ["*", "category.*"] → all columns + all related category columns
|
|
526
|
+
- ["name", "category.name", "author.email"] → specific fields from main + related tables
|
|
527
|
+
- ["*", "author.profile.*"] → deep nested relations (up to 7 levels)
|
|
528
|
+
The primary key is always returned.`, {
|
|
529
|
+
collection: z.string().describe("Table/collection name"),
|
|
530
|
+
id: z.string().describe("Row ID (UUID)"),
|
|
531
|
+
fields: z.array(z.string()).optional().describe("Columns to return. Use [\"*\"] for all, dot notation for relations: [\"*\", \"category.*\"]"),
|
|
412
532
|
}, async (args, extra) => {
|
|
413
533
|
const { collection, id, fields } = args;
|
|
414
534
|
try {
|
|
415
|
-
const
|
|
416
|
-
const itemsService = new ItemsService(collection, { accountability });
|
|
417
|
-
const query = {};
|
|
535
|
+
const params = new URLSearchParams();
|
|
418
536
|
if (fields)
|
|
419
|
-
|
|
420
|
-
const
|
|
421
|
-
|
|
537
|
+
params.set('fields', JSON.stringify(fields));
|
|
538
|
+
const qs = params.toString();
|
|
539
|
+
const res = await callRoute('GET', `/items/${encodeURIComponent(collection)}/${encodeURIComponent(id)}${qs ? '?' + qs : ''}`, extra);
|
|
540
|
+
if (!res.ok)
|
|
541
|
+
return errorResult(res.error || 'Failed to get item');
|
|
542
|
+
return successResult(res.data);
|
|
422
543
|
}
|
|
423
544
|
catch (error) {
|
|
424
545
|
return errorResult(error);
|
|
425
546
|
}
|
|
426
547
|
});
|
|
427
|
-
server.tool("baasix_create_item",
|
|
428
|
-
|
|
429
|
-
|
|
548
|
+
server.tool("baasix_create_item", `Insert a new row/record into a database table. Use this when asked to 'add data', 'insert a record', or 'create an entry'.
|
|
549
|
+
|
|
550
|
+
Pass field values as a key-value object. For UUID primary keys with UUIDV4 default, you can omit the id field — it will be auto-generated.
|
|
551
|
+
For tables with timestamps: true, createdAt and updatedAt are auto-managed.
|
|
552
|
+
For foreign key fields, use the "_Id" suffixed column name (e.g., category_Id, author_Id).
|
|
553
|
+
|
|
554
|
+
EXAMPLE: collection: "products", data: {"name": "Widget", "price": 9.99, "category_Id": "<uuid>", "inStock": true}`, {
|
|
555
|
+
collection: z.string().describe("Table/collection name to insert into"),
|
|
556
|
+
data: z.record(z.any()).describe("Row data as {columnName: value}. FK columns use _Id suffix (e.g., category_Id)."),
|
|
430
557
|
}, async (args, extra) => {
|
|
431
558
|
const { collection, data } = args;
|
|
432
559
|
try {
|
|
433
|
-
const
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
return successResult(
|
|
560
|
+
const res = await callRoute('POST', `/items/${encodeURIComponent(collection)}`, extra, data);
|
|
561
|
+
if (!res.ok)
|
|
562
|
+
return errorResult(res.error || 'Failed to create item');
|
|
563
|
+
return successResult(res.data);
|
|
437
564
|
}
|
|
438
565
|
catch (error) {
|
|
439
566
|
return errorResult(error);
|
|
440
567
|
}
|
|
441
568
|
});
|
|
442
|
-
server.tool("baasix_update_item",
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
569
|
+
server.tool("baasix_update_item", `Update/modify an existing row/record in a database table by its ID. Only pass the fields you want to change — unspecified fields remain unchanged.
|
|
570
|
+
|
|
571
|
+
For tables with timestamps: true, updatedAt is automatically updated.
|
|
572
|
+
For foreign key fields, use the "_Id" suffixed column name (e.g., category_Id).`, {
|
|
573
|
+
collection: z.string().describe("Table/collection name"),
|
|
574
|
+
id: z.string().describe("Row ID (UUID) to update"),
|
|
575
|
+
data: z.record(z.any()).describe("Fields to update as {columnName: newValue}. Only changed fields needed."),
|
|
446
576
|
}, async (args, extra) => {
|
|
447
577
|
const { collection, id, data } = args;
|
|
448
578
|
try {
|
|
449
|
-
const
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
return successResult(
|
|
579
|
+
const res = await callRoute('PATCH', `/items/${encodeURIComponent(collection)}/${encodeURIComponent(id)}`, extra, data);
|
|
580
|
+
if (!res.ok)
|
|
581
|
+
return errorResult(res.error || 'Failed to update item');
|
|
582
|
+
return successResult(res.data);
|
|
453
583
|
}
|
|
454
584
|
catch (error) {
|
|
455
585
|
return errorResult(error);
|
|
456
586
|
}
|
|
457
587
|
});
|
|
458
|
-
server.tool("baasix_delete_item", "Delete
|
|
459
|
-
collection: z.string().describe("
|
|
460
|
-
id: z.string().describe("
|
|
588
|
+
server.tool("baasix_delete_item", "Delete a row/record from a database table by its ID. For tables with paranoid: true (soft delete), the record is marked as deleted (deletedAt is set) rather than permanently removed.", {
|
|
589
|
+
collection: z.string().describe("Table/collection name"),
|
|
590
|
+
id: z.string().describe("Row ID (UUID) to delete"),
|
|
461
591
|
}, async (args, extra) => {
|
|
462
592
|
const { collection, id } = args;
|
|
463
593
|
try {
|
|
464
|
-
const
|
|
465
|
-
|
|
466
|
-
|
|
594
|
+
const res = await callRoute('DELETE', `/items/${encodeURIComponent(collection)}/${encodeURIComponent(id)}`, extra);
|
|
595
|
+
if (!res.ok)
|
|
596
|
+
return errorResult(res.error || 'Failed to delete item');
|
|
467
597
|
return successResult({ success: true, message: `Item '${id}' deleted` });
|
|
468
598
|
}
|
|
469
599
|
catch (error) {
|
|
@@ -471,52 +601,57 @@ FILTER EXAMPLES:
|
|
|
471
601
|
}
|
|
472
602
|
});
|
|
473
603
|
// ==================== File Management Tools ====================
|
|
474
|
-
server.tool("baasix_list_files",
|
|
475
|
-
|
|
604
|
+
server.tool("baasix_list_files", `List uploaded files with metadata (filename, size, type, dimensions, storage location).
|
|
605
|
+
|
|
606
|
+
Filter examples:
|
|
607
|
+
By type: {"type": {"startsWith": "image/"}}
|
|
608
|
+
By name: {"originalFilename": {"iLike": "%report%"}}
|
|
609
|
+
Public files only: {"isPublic": {"eq": true}}`, {
|
|
610
|
+
filter: z.record(z.any()).optional().describe("Filter using same operators as baasix_list_items: {\"type\": {\"startsWith\": \"image/\"}}"),
|
|
476
611
|
page: z.number().optional().default(1).describe("Page number"),
|
|
477
|
-
limit: z.number().optional().default(10).describe("Files per page"),
|
|
612
|
+
limit: z.number().optional().default(10).describe("Files per page. Use -1 for all."),
|
|
478
613
|
}, async (args, extra) => {
|
|
479
614
|
const { filter, page, limit } = args;
|
|
480
615
|
try {
|
|
481
|
-
const
|
|
482
|
-
|
|
483
|
-
|
|
616
|
+
const params = new URLSearchParams();
|
|
617
|
+
if (page)
|
|
618
|
+
params.set('page', String(page));
|
|
619
|
+
if (limit)
|
|
620
|
+
params.set('limit', String(limit));
|
|
484
621
|
if (filter)
|
|
485
|
-
|
|
486
|
-
const
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
limit,
|
|
492
|
-
});
|
|
622
|
+
params.set('filter', JSON.stringify(filter));
|
|
623
|
+
const qs = params.toString();
|
|
624
|
+
const res = await callRoute('GET', `/files${qs ? '?' + qs : ''}`, extra);
|
|
625
|
+
if (!res.ok)
|
|
626
|
+
return errorResult(res.error || 'Failed to list files');
|
|
627
|
+
return successResult(res.data);
|
|
493
628
|
}
|
|
494
629
|
catch (error) {
|
|
495
630
|
return errorResult(error);
|
|
496
631
|
}
|
|
497
632
|
});
|
|
498
|
-
server.tool("baasix_get_file_info", "Get detailed
|
|
499
|
-
id: z.string().describe("File
|
|
633
|
+
server.tool("baasix_get_file_info", "Get detailed metadata about a specific uploaded file — filename, size, MIME type, dimensions, storage location, and who uploaded it.", {
|
|
634
|
+
id: z.string().describe("File UUID"),
|
|
500
635
|
}, async (args, extra) => {
|
|
501
636
|
const { id } = args;
|
|
502
637
|
try {
|
|
503
|
-
const
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
return successResult(
|
|
638
|
+
const res = await callRoute('GET', `/files/${encodeURIComponent(id)}`, extra);
|
|
639
|
+
if (!res.ok)
|
|
640
|
+
return errorResult(res.error || 'Failed to get file info');
|
|
641
|
+
return successResult(res.data);
|
|
507
642
|
}
|
|
508
643
|
catch (error) {
|
|
509
644
|
return errorResult(error);
|
|
510
645
|
}
|
|
511
646
|
});
|
|
512
|
-
server.tool("baasix_delete_file", "Delete
|
|
513
|
-
id: z.string().describe("File
|
|
647
|
+
server.tool("baasix_delete_file", "Delete an uploaded file by its UUID. Removes both the file record and the actual file from storage.", {
|
|
648
|
+
id: z.string().describe("File UUID"),
|
|
514
649
|
}, async (args, extra) => {
|
|
515
650
|
const { id } = args;
|
|
516
651
|
try {
|
|
517
|
-
const
|
|
518
|
-
|
|
519
|
-
|
|
652
|
+
const res = await callRoute('DELETE', `/files/${encodeURIComponent(id)}`, extra);
|
|
653
|
+
if (!res.ok)
|
|
654
|
+
return errorResult(res.error || 'Failed to delete file');
|
|
520
655
|
return successResult({ success: true, message: `File '${id}' deleted` });
|
|
521
656
|
}
|
|
522
657
|
catch (error) {
|
|
@@ -524,70 +659,117 @@ FILTER EXAMPLES:
|
|
|
524
659
|
}
|
|
525
660
|
});
|
|
526
661
|
// ==================== Permission Tools ====================
|
|
527
|
-
server.tool("baasix_list_roles", "List all
|
|
662
|
+
server.tool("baasix_list_roles", "List all user roles with their IDs, names, and descriptions. Use this to get role UUIDs needed for permission tools. Default roles: 'administrator' (full access), 'public' (unauthenticated access). Custom roles can be created for granular access control.", {}, async (_args, extra) => {
|
|
528
663
|
try {
|
|
529
|
-
const
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
return successResult({ data:
|
|
664
|
+
const res = await callRoute('GET', '/items/baasix_Role?limit=-1', extra);
|
|
665
|
+
if (!res.ok)
|
|
666
|
+
return errorResult(res.error || 'Failed to list roles');
|
|
667
|
+
return successResult({ data: res.data?.data || res.data });
|
|
533
668
|
}
|
|
534
669
|
catch (error) {
|
|
535
670
|
return errorResult(error);
|
|
536
671
|
}
|
|
537
672
|
});
|
|
538
|
-
server.tool("baasix_list_permissions",
|
|
539
|
-
|
|
540
|
-
|
|
673
|
+
server.tool("baasix_list_permissions", `List all access control permission rules with optional filtering.
|
|
674
|
+
|
|
675
|
+
Each permission defines: role_Id (which role), collection (which table), action (create/read/update/delete), fields (which columns), conditions (row-level security), defaultValues (auto-set values), and relConditions (related table security).
|
|
676
|
+
|
|
677
|
+
Filter examples:
|
|
678
|
+
All permissions for a role: {"role_Id": {"eq": "<role-uuid>"}}
|
|
679
|
+
All read permissions: {"action": {"eq": "read"}}
|
|
680
|
+
Permissions for a table: {"collection": {"eq": "products"}}`, {
|
|
681
|
+
filter: z.record(z.any()).optional().describe("Filter: {\"role_Id\": {\"eq\": \"<uuid>\"}}, {\"collection\": {\"eq\": \"products\"}}, {\"action\": {\"eq\": \"read\"}}"),
|
|
682
|
+
sort: z.string().optional().describe("Sort as 'field:asc' or 'field:desc'"),
|
|
541
683
|
page: z.number().optional().default(1).describe("Page number"),
|
|
542
|
-
limit: z.number().optional().default(10).describe("Permissions per page"),
|
|
684
|
+
limit: z.number().optional().default(10).describe("Permissions per page. Use -1 for all."),
|
|
543
685
|
}, async (args, extra) => {
|
|
544
686
|
const { filter, sort, page, limit } = args;
|
|
545
687
|
try {
|
|
546
|
-
const
|
|
547
|
-
|
|
548
|
-
|
|
688
|
+
const params = new URLSearchParams();
|
|
689
|
+
if (page)
|
|
690
|
+
params.set('page', String(page));
|
|
691
|
+
if (limit)
|
|
692
|
+
params.set('limit', String(limit));
|
|
549
693
|
if (filter)
|
|
550
|
-
|
|
694
|
+
params.set('filter', JSON.stringify(filter));
|
|
551
695
|
if (sort) {
|
|
552
696
|
const [field, direction] = sort.split(":");
|
|
553
|
-
|
|
697
|
+
params.set('sort', JSON.stringify([direction?.toLowerCase() === "desc" ? `-${field}` : field]));
|
|
554
698
|
}
|
|
555
|
-
const
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
limit,
|
|
561
|
-
});
|
|
699
|
+
const qs = params.toString();
|
|
700
|
+
const res = await callRoute('GET', `/permissions${qs ? '?' + qs : ''}`, extra);
|
|
701
|
+
if (!res.ok)
|
|
702
|
+
return errorResult(res.error || 'Failed to list permissions');
|
|
703
|
+
return successResult(res.data);
|
|
562
704
|
}
|
|
563
705
|
catch (error) {
|
|
564
706
|
return errorResult(error);
|
|
565
707
|
}
|
|
566
708
|
});
|
|
567
|
-
server.tool("baasix_create_permission", `
|
|
709
|
+
server.tool("baasix_create_permission", `Grant a role permission to perform an action on a table. Use this to set up access control (RBAC + row-level security).
|
|
568
710
|
|
|
569
|
-
|
|
711
|
+
First use baasix_list_roles to get the role UUID, then create permissions for that role.
|
|
570
712
|
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
713
|
+
--- ACTIONS ---
|
|
714
|
+
create: allow inserting new rows
|
|
715
|
+
read: allow querying/viewing rows
|
|
716
|
+
update: allow modifying existing rows
|
|
717
|
+
delete: allow deleting rows
|
|
718
|
+
Each action needs its own permission rule. A role with no permissions for a table cannot access it at all.
|
|
574
719
|
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
720
|
+
--- FIELDS (column-level access control) ---
|
|
721
|
+
["*"] → allow access to ALL columns
|
|
722
|
+
["name", "price", "status"] → allow access to ONLY these columns
|
|
723
|
+
For read: controls which columns are returned. For create/update: controls which columns can be written.
|
|
724
|
+
|
|
725
|
+
--- CONDITIONS (row-level security / RLS) ---
|
|
726
|
+
Uses the same filter operators as baasix_list_items. These conditions are enforced as security constraints — always ANDed with any user query, cannot be bypassed.
|
|
727
|
+
Only published: {"published": {"eq": true}}
|
|
728
|
+
Only own records: {"author_Id": {"eq": "$CURRENT_USER"}}
|
|
729
|
+
Only own tenant: {"tenant_Id": {"eq": "$CURRENT_USER.tenant_Id"}}
|
|
730
|
+
Multiple conditions: {"AND": [{"status": {"in": ["active", "draft"]}}, {"author_Id": {"eq": "$CURRENT_USER"}}]}
|
|
731
|
+
|
|
732
|
+
DYNAMIC VARIABLES in conditions:
|
|
733
|
+
$CURRENT_USER → current user's ID
|
|
734
|
+
$CURRENT_USER.fieldName → any field on the user (e.g., $CURRENT_USER.tenant_Id, $CURRENT_USER.department_Id)
|
|
735
|
+
$CURRENT_ROLE → current role ID
|
|
736
|
+
$NOW, $NOW+DAYS_7, $NOW-MONTHS_1 → timestamp math
|
|
737
|
+
|
|
738
|
+
--- RELCONDITIONS (row-level security on RELATED tables) ---
|
|
739
|
+
Restrict access based on data in related tables. Keys are relation names.
|
|
740
|
+
{"category": {"isPublic": {"eq": true}}} → only allow access if the related category is public
|
|
741
|
+
{"organization": {"members": {"user_Id": {"eq": "$CURRENT_USER"}}}} → nested relation check
|
|
742
|
+
These are merged with any query-level relConditions using AND.
|
|
743
|
+
|
|
744
|
+
--- DEFAULTVALUES (auto-injected values on create/update) ---
|
|
745
|
+
Values automatically set when this role creates or updates records. User-provided values override these.
|
|
746
|
+
{"status": "draft"} → new records default to draft status
|
|
747
|
+
{"author_Id": "$CURRENT_USER"} → auto-set author to current user
|
|
748
|
+
{"tenant_Id": "$CURRENT_USER.tenant_Id"} → auto-set tenant
|
|
749
|
+
Supports all dynamic variables.
|
|
750
|
+
|
|
751
|
+
--- EXAMPLES ---
|
|
752
|
+
Allow editors to read all products:
|
|
753
|
+
role_Id: "<uuid>", collection: "products", action: "read", fields: ["*"]
|
|
754
|
+
|
|
755
|
+
Allow users to only read their own posts:
|
|
756
|
+
role_Id: "<uuid>", collection: "posts", action: "read", fields: ["*"], conditions: {"author_Id": {"eq": "$CURRENT_USER"}}
|
|
757
|
+
|
|
758
|
+
Allow users to create posts with auto-set author:
|
|
759
|
+
role_Id: "<uuid>", collection: "posts", action: "create", fields: ["title", "content"], defaultValues: {"author_Id": "$CURRENT_USER", "status": "draft"}
|
|
760
|
+
|
|
761
|
+
Allow users to only update their own posts, only title and content:
|
|
762
|
+
role_Id: "<uuid>", collection: "posts", action: "update", fields: ["title", "content"], conditions: {"author_Id": {"eq": "$CURRENT_USER"}}`, {
|
|
763
|
+
role_Id: z.string().describe("Role UUID — get this from baasix_list_roles"),
|
|
764
|
+
collection: z.string().describe("Table/collection name this permission applies to"),
|
|
765
|
+
action: z.enum(["create", "read", "update", "delete"]).describe("The CRUD action to allow"),
|
|
766
|
+
fields: z.array(z.string()).optional().describe("Allowed columns: [\"*\"] for all, or [\"name\", \"price\"] for specific. Omit for all."),
|
|
767
|
+
conditions: z.record(z.any()).optional().describe("Row-level security filter (same operators as filter in baasix_list_items). Always enforced."),
|
|
768
|
+
defaultValues: z.record(z.any()).optional().describe("Auto-injected values on create/update: {\"author_Id\": \"$CURRENT_USER\", \"status\": \"draft\"}"),
|
|
769
|
+
relConditions: z.record(z.any()).optional().describe("Row-level security on related tables: {\"category\": {\"isPublic\": {\"eq\": true}}}"),
|
|
586
770
|
}, async (args, extra) => {
|
|
587
771
|
const { role_Id, collection, action, fields, conditions, defaultValues, relConditions } = args;
|
|
588
772
|
try {
|
|
589
|
-
const accountability = getAccountability(extra);
|
|
590
|
-
const permService = new ItemsService("baasix_Permission", { accountability });
|
|
591
773
|
const data = { role_Id, collection, action };
|
|
592
774
|
if (fields)
|
|
593
775
|
data.fields = fields;
|
|
@@ -597,145 +779,88 @@ CONDITIONS (Row-level security):
|
|
|
597
779
|
data.defaultValues = defaultValues;
|
|
598
780
|
if (relConditions)
|
|
599
781
|
data.relConditions = relConditions;
|
|
600
|
-
const
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
return successResult(
|
|
782
|
+
const res = await callRoute('POST', '/permissions', extra, data);
|
|
783
|
+
if (!res.ok)
|
|
784
|
+
return errorResult(res.error || 'Failed to create permission');
|
|
785
|
+
return successResult(res.data);
|
|
604
786
|
}
|
|
605
787
|
catch (error) {
|
|
606
788
|
return errorResult(error);
|
|
607
789
|
}
|
|
608
790
|
});
|
|
609
|
-
server.tool("baasix_update_permission",
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
791
|
+
server.tool("baasix_update_permission", `Update an existing permission rule. Only pass the fields you want to change — unspecified fields remain unchanged.
|
|
792
|
+
|
|
793
|
+
See baasix_create_permission for full documentation of conditions, relConditions, defaultValues, and fields options.
|
|
794
|
+
Use baasix_list_permissions or baasix_get_permissions to find the permission ID to update.`, {
|
|
795
|
+
id: z.string().describe("Permission UUID — get from baasix_list_permissions or baasix_get_permissions"),
|
|
796
|
+
role_Id: z.string().optional().describe("Change which role this permission applies to"),
|
|
797
|
+
collection: z.string().optional().describe("Change which table this permission applies to"),
|
|
798
|
+
action: z.enum(["create", "read", "update", "delete"]).optional().describe("Change the CRUD action"),
|
|
799
|
+
fields: z.array(z.string()).optional().describe("Change allowed columns: [\"*\"] for all, or specific column names"),
|
|
800
|
+
conditions: z.record(z.any()).optional().describe("Change row-level security conditions (same operators as filter)"),
|
|
801
|
+
defaultValues: z.record(z.any()).optional().describe("Change auto-injected values on create/update"),
|
|
802
|
+
relConditions: z.record(z.any()).optional().describe("Change row-level security on related tables"),
|
|
618
803
|
}, async (args, extra) => {
|
|
619
804
|
const { id, ...updateData } = args;
|
|
620
805
|
try {
|
|
621
|
-
const
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
await permissionService.loadPermissions();
|
|
626
|
-
return successResult({ data: result });
|
|
806
|
+
const res = await callRoute('PATCH', `/permissions/${encodeURIComponent(id)}`, extra, updateData);
|
|
807
|
+
if (!res.ok)
|
|
808
|
+
return errorResult(res.error || 'Failed to update permission');
|
|
809
|
+
return successResult(res.data);
|
|
627
810
|
}
|
|
628
811
|
catch (error) {
|
|
629
812
|
return errorResult(error);
|
|
630
813
|
}
|
|
631
814
|
});
|
|
632
|
-
server.tool("baasix_delete_permission", "Delete a permission", {
|
|
633
|
-
id: z.string().describe("Permission
|
|
815
|
+
server.tool("baasix_delete_permission", "Delete a permission rule by its UUID. This revokes the access it granted. The permission cache is automatically reloaded.", {
|
|
816
|
+
id: z.string().describe("Permission UUID — get from baasix_list_permissions or baasix_get_permissions"),
|
|
634
817
|
}, async (args, extra) => {
|
|
635
818
|
const { id } = args;
|
|
636
819
|
try {
|
|
637
|
-
const
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
// Reload permissions cache
|
|
641
|
-
await permissionService.loadPermissions();
|
|
820
|
+
const res = await callRoute('DELETE', `/permissions/${encodeURIComponent(id)}`, extra);
|
|
821
|
+
if (!res.ok)
|
|
822
|
+
return errorResult(res.error || 'Failed to delete permission');
|
|
642
823
|
return successResult({ success: true, message: `Permission '${id}' deleted` });
|
|
643
824
|
}
|
|
644
825
|
catch (error) {
|
|
645
826
|
return errorResult(error);
|
|
646
827
|
}
|
|
647
828
|
});
|
|
648
|
-
server.tool("baasix_reload_permissions", "
|
|
829
|
+
server.tool("baasix_reload_permissions", "Force-reload the permission cache from the database. Normally not needed as create/update/delete permission tools auto-reload. Use this if permissions seem stale.", {}, async (_args, extra) => {
|
|
649
830
|
try {
|
|
650
|
-
await
|
|
831
|
+
const res = await callRoute('POST', '/permissions/reload', extra);
|
|
832
|
+
if (!res.ok)
|
|
833
|
+
return errorResult(res.error || 'Failed to reload permissions');
|
|
651
834
|
return successResult({ success: true, message: "Permissions reloaded" });
|
|
652
835
|
}
|
|
653
836
|
catch (error) {
|
|
654
837
|
return errorResult(error);
|
|
655
838
|
}
|
|
656
839
|
});
|
|
657
|
-
// ==================== Realtime Tools ====================
|
|
658
|
-
server.tool("baasix_realtime_status", "Get the status of the realtime service including WAL configuration", {}, async (_args, _extra) => {
|
|
659
|
-
try {
|
|
660
|
-
const status = await realtimeService.getStatus();
|
|
661
|
-
return successResult(status);
|
|
662
|
-
}
|
|
663
|
-
catch (error) {
|
|
664
|
-
return errorResult(error);
|
|
665
|
-
}
|
|
666
|
-
});
|
|
667
|
-
server.tool("baasix_realtime_config", "Check PostgreSQL replication configuration for WAL-based realtime", {}, async (_args, _extra) => {
|
|
668
|
-
try {
|
|
669
|
-
const config = await realtimeService.getConfig();
|
|
670
|
-
return successResult(config);
|
|
671
|
-
}
|
|
672
|
-
catch (error) {
|
|
673
|
-
return errorResult(error);
|
|
674
|
-
}
|
|
675
|
-
});
|
|
676
|
-
server.tool("baasix_realtime_collections", "Get list of collections with realtime enabled", {}, async (_args, _extra) => {
|
|
677
|
-
try {
|
|
678
|
-
const collections = realtimeService.getRealtimeCollections();
|
|
679
|
-
return successResult({ collections });
|
|
680
|
-
}
|
|
681
|
-
catch (error) {
|
|
682
|
-
return errorResult(error);
|
|
683
|
-
}
|
|
684
|
-
});
|
|
685
|
-
server.tool("baasix_realtime_enable", "Enable realtime for a collection", {
|
|
686
|
-
collection: z.string().describe("Collection name"),
|
|
687
|
-
actions: z.array(z.enum(["insert", "update", "delete"])).optional().describe("Actions to broadcast"),
|
|
688
|
-
replicaIdentityFull: z.boolean().optional().describe("Set REPLICA IDENTITY FULL for old values"),
|
|
689
|
-
}, async (args, _extra) => {
|
|
690
|
-
const { collection, actions, replicaIdentityFull } = args;
|
|
691
|
-
try {
|
|
692
|
-
await realtimeService.enableRealtime(collection, {
|
|
693
|
-
actions: actions || ["insert", "update", "delete"],
|
|
694
|
-
replicaIdentityFull: replicaIdentityFull || false,
|
|
695
|
-
});
|
|
696
|
-
return successResult({
|
|
697
|
-
success: true,
|
|
698
|
-
message: `Realtime enabled for '${collection}'`,
|
|
699
|
-
});
|
|
700
|
-
}
|
|
701
|
-
catch (error) {
|
|
702
|
-
return errorResult(error);
|
|
703
|
-
}
|
|
704
|
-
});
|
|
705
|
-
server.tool("baasix_realtime_disable", "Disable realtime for a collection", {
|
|
706
|
-
collection: z.string().describe("Collection name"),
|
|
707
|
-
}, async (args, _extra) => {
|
|
708
|
-
const { collection } = args;
|
|
709
|
-
try {
|
|
710
|
-
await realtimeService.disableRealtime(collection);
|
|
711
|
-
return successResult({
|
|
712
|
-
success: true,
|
|
713
|
-
message: `Realtime disabled for '${collection}'`,
|
|
714
|
-
});
|
|
715
|
-
}
|
|
716
|
-
catch (error) {
|
|
717
|
-
return errorResult(error);
|
|
718
|
-
}
|
|
719
|
-
});
|
|
720
840
|
// ==================== Settings Tools ====================
|
|
721
|
-
server.tool("baasix_get_settings", "Get application settings", {
|
|
722
|
-
key: z.string().optional().describe("Specific setting key to retrieve"),
|
|
723
|
-
}, async (args,
|
|
841
|
+
server.tool("baasix_get_settings", "Get application settings. Pass a key to get a specific setting, or omit to get all settings. Settings include project info, auth config, email config, etc.", {
|
|
842
|
+
key: z.string().optional().describe("Specific setting key to retrieve (e.g., 'project_name', 'auth_password_policy'). Omit for all settings."),
|
|
843
|
+
}, async (args, extra) => {
|
|
724
844
|
const { key } = args;
|
|
725
845
|
try {
|
|
726
|
-
const
|
|
727
|
-
|
|
846
|
+
const path = key ? `/settings?key=${encodeURIComponent(key)}` : '/settings';
|
|
847
|
+
const res = await callRoute('GET', path, extra);
|
|
848
|
+
if (!res.ok)
|
|
849
|
+
return errorResult(res.error || 'Failed to get settings');
|
|
850
|
+
return successResult({ data: res.data });
|
|
728
851
|
}
|
|
729
852
|
catch (error) {
|
|
730
853
|
return errorResult(error);
|
|
731
854
|
}
|
|
732
855
|
});
|
|
733
|
-
server.tool("baasix_update_settings", "Update application settings", {
|
|
734
|
-
settings: z.record(z.any()).describe("Settings
|
|
735
|
-
}, async (args,
|
|
856
|
+
server.tool("baasix_update_settings", "Update application settings. Pass an object with the settings keys and their new values. Only specified keys are updated.", {
|
|
857
|
+
settings: z.record(z.any()).describe("Settings to update as {key: value}. Use baasix_get_settings first to see available keys."),
|
|
858
|
+
}, async (args, extra) => {
|
|
736
859
|
const { settings } = args;
|
|
737
860
|
try {
|
|
738
|
-
await
|
|
861
|
+
const res = await callRoute('PATCH', '/settings', extra, settings);
|
|
862
|
+
if (!res.ok)
|
|
863
|
+
return errorResult(res.error || 'Failed to update settings');
|
|
739
864
|
return successResult({
|
|
740
865
|
success: true,
|
|
741
866
|
message: "Settings updated",
|
|
@@ -746,25 +871,25 @@ CONDITIONS (Row-level security):
|
|
|
746
871
|
}
|
|
747
872
|
});
|
|
748
873
|
// ==================== Email Template Tools ====================
|
|
749
|
-
server.tool("baasix_list_templates", "List all email templates
|
|
874
|
+
server.tool("baasix_list_templates", "List all email templates (invitation, password reset, welcome, etc.).", {
|
|
750
875
|
filter: z.record(z.any()).optional().describe("Filter criteria"),
|
|
751
876
|
page: z.number().optional().default(1).describe("Page number"),
|
|
752
877
|
limit: z.number().optional().default(10).describe("Templates per page"),
|
|
753
878
|
}, async (args, extra) => {
|
|
754
879
|
const { filter, page, limit } = args;
|
|
755
880
|
try {
|
|
756
|
-
const
|
|
757
|
-
|
|
758
|
-
|
|
881
|
+
const params = new URLSearchParams();
|
|
882
|
+
if (page)
|
|
883
|
+
params.set('page', String(page));
|
|
884
|
+
if (limit)
|
|
885
|
+
params.set('limit', String(limit));
|
|
759
886
|
if (filter)
|
|
760
|
-
|
|
761
|
-
const
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
limit,
|
|
767
|
-
});
|
|
887
|
+
params.set('filter', JSON.stringify(filter));
|
|
888
|
+
const qs = params.toString();
|
|
889
|
+
const res = await callRoute('GET', `/items/baasix_Template${qs ? '?' + qs : ''}`, extra);
|
|
890
|
+
if (!res.ok)
|
|
891
|
+
return errorResult(res.error || 'Failed to list templates');
|
|
892
|
+
return successResult(res.data);
|
|
768
893
|
}
|
|
769
894
|
catch (error) {
|
|
770
895
|
return errorResult(error);
|
|
@@ -775,10 +900,10 @@ CONDITIONS (Row-level security):
|
|
|
775
900
|
}, async (args, extra) => {
|
|
776
901
|
const { id } = args;
|
|
777
902
|
try {
|
|
778
|
-
const
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
return successResult(
|
|
903
|
+
const res = await callRoute('GET', `/items/baasix_Template/${encodeURIComponent(id)}`, extra);
|
|
904
|
+
if (!res.ok)
|
|
905
|
+
return errorResult(res.error || 'Failed to get template');
|
|
906
|
+
return successResult(res.data);
|
|
782
907
|
}
|
|
783
908
|
catch (error) {
|
|
784
909
|
return errorResult(error);
|
|
@@ -805,57 +930,56 @@ AVAILABLE VARIABLES:
|
|
|
805
930
|
}, async (args, extra) => {
|
|
806
931
|
const { id, ...updateData } = args;
|
|
807
932
|
try {
|
|
808
|
-
const
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
return successResult(
|
|
933
|
+
const res = await callRoute('PATCH', `/items/baasix_Template/${encodeURIComponent(id)}`, extra, updateData);
|
|
934
|
+
if (!res.ok)
|
|
935
|
+
return errorResult(res.error || 'Failed to update template');
|
|
936
|
+
return successResult(res.data);
|
|
812
937
|
}
|
|
813
938
|
catch (error) {
|
|
814
939
|
return errorResult(error);
|
|
815
940
|
}
|
|
816
941
|
});
|
|
817
942
|
// ==================== Notification Tools ====================
|
|
818
|
-
server.tool("baasix_list_notifications", "List notifications for the authenticated user", {
|
|
943
|
+
server.tool("baasix_list_notifications", "List in-app notifications for the current authenticated user. Filter by seen/unseen status to find unread notifications.", {
|
|
819
944
|
page: z.number().optional().default(1).describe("Page number"),
|
|
820
|
-
limit: z.number().optional().default(10).describe("Notifications per page"),
|
|
821
|
-
seen: z.boolean().optional().describe("Filter
|
|
945
|
+
limit: z.number().optional().default(10).describe("Notifications per page. Use -1 for all."),
|
|
946
|
+
seen: z.boolean().optional().describe("Filter: true = only seen, false = only unseen, omit = all"),
|
|
822
947
|
}, async (args, extra) => {
|
|
823
948
|
const { page, limit, seen } = args;
|
|
824
949
|
try {
|
|
825
|
-
const
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
if (
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
});
|
|
950
|
+
const params = new URLSearchParams();
|
|
951
|
+
if (page)
|
|
952
|
+
params.set('page', String(page));
|
|
953
|
+
if (limit)
|
|
954
|
+
params.set('limit', String(limit));
|
|
955
|
+
if (seen !== undefined)
|
|
956
|
+
params.set('seen', String(seen));
|
|
957
|
+
const qs = params.toString();
|
|
958
|
+
const res = await callRoute('GET', `/notifications${qs ? '?' + qs : ''}`, extra);
|
|
959
|
+
if (!res.ok)
|
|
960
|
+
return errorResult(res.error || 'Failed to list notifications');
|
|
961
|
+
return successResult(res.data);
|
|
838
962
|
}
|
|
839
963
|
catch (error) {
|
|
840
964
|
return errorResult(error);
|
|
841
965
|
}
|
|
842
966
|
});
|
|
843
|
-
server.tool("baasix_mark_notification_seen", "Mark a notification as seen", {
|
|
844
|
-
id: z.string().describe("Notification
|
|
967
|
+
server.tool("baasix_mark_notification_seen", "Mark a notification as seen/read by its UUID.", {
|
|
968
|
+
id: z.string().describe("Notification UUID"),
|
|
845
969
|
}, async (args, extra) => {
|
|
846
970
|
const { id } = args;
|
|
847
971
|
try {
|
|
848
|
-
const
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
return successResult(
|
|
972
|
+
const res = await callRoute('POST', '/notifications/mark-seen', extra, { notificationIds: [id] });
|
|
973
|
+
if (!res.ok)
|
|
974
|
+
return errorResult(res.error || 'Failed to mark notification as seen');
|
|
975
|
+
return successResult(res.data);
|
|
852
976
|
}
|
|
853
977
|
catch (error) {
|
|
854
978
|
return errorResult(error);
|
|
855
979
|
}
|
|
856
980
|
});
|
|
857
981
|
// ==================== Utility Tools ====================
|
|
858
|
-
server.tool("baasix_server_info", "Get
|
|
982
|
+
server.tool("baasix_server_info", "Get server health, version, uptime, and memory usage.", {}, async (_args, _extra) => {
|
|
859
983
|
try {
|
|
860
984
|
const info = {
|
|
861
985
|
name: "baasix",
|
|
@@ -876,162 +1000,192 @@ AVAILABLE VARIABLES:
|
|
|
876
1000
|
return errorResult(error);
|
|
877
1001
|
}
|
|
878
1002
|
});
|
|
879
|
-
server.tool("baasix_sort_items",
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
1003
|
+
server.tool("baasix_sort_items", `Reorder a row within a sortable table — move it before or after another row. The table must have a "sort" field.
|
|
1004
|
+
|
|
1005
|
+
MODE:
|
|
1006
|
+
- "before" (default): Places item directly before the target row
|
|
1007
|
+
- "after": Places item directly after the target row
|
|
1008
|
+
|
|
1009
|
+
EXAMPLE: Move task "abc" before task "xyz":
|
|
1010
|
+
collection: "tasks", item: "abc", to: "xyz", mode: "before"`, {
|
|
1011
|
+
collection: z.string().describe("Table/collection name (must have a sort field)"),
|
|
1012
|
+
item: z.string().describe("UUID of the row to move"),
|
|
1013
|
+
to: z.string().describe("UUID of the target row to position relative to"),
|
|
1014
|
+
mode: z.enum(["before", "after"]).optional().default("before").describe("'before' = place item before target (default), 'after' = place item after target"),
|
|
883
1015
|
}, async (args, extra) => {
|
|
884
|
-
const { collection, item, to } = args;
|
|
1016
|
+
const { collection, item, to, mode } = args;
|
|
885
1017
|
try {
|
|
1018
|
+
const { sortItems } = await import("../utils/sortUtils.js");
|
|
886
1019
|
const accountability = getAccountability(extra);
|
|
887
|
-
const
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
const [sourceItem, targetItem] = await Promise.all([itemsService.readOne(item), itemsService.readOne(to)]);
|
|
895
|
-
// Swap sort values
|
|
896
|
-
await Promise.all([
|
|
897
|
-
itemsService.updateOne(item, { sort: targetItem.sort }),
|
|
898
|
-
itemsService.updateOne(to, { sort: sourceItem.sort }),
|
|
899
|
-
]);
|
|
1020
|
+
const result = await sortItems({
|
|
1021
|
+
collection,
|
|
1022
|
+
item,
|
|
1023
|
+
to,
|
|
1024
|
+
mode: mode || 'before',
|
|
1025
|
+
accountability,
|
|
1026
|
+
});
|
|
900
1027
|
return successResult({
|
|
901
1028
|
success: true,
|
|
902
|
-
|
|
1029
|
+
item: result.item,
|
|
1030
|
+
collection: result.collection,
|
|
1031
|
+
newSort: result.newSort,
|
|
1032
|
+
message: `Item '${item}' moved ${mode || 'before'} '${to}'`,
|
|
903
1033
|
});
|
|
904
1034
|
}
|
|
905
1035
|
catch (error) {
|
|
906
1036
|
return errorResult(error);
|
|
907
1037
|
}
|
|
908
1038
|
});
|
|
909
|
-
server.tool("baasix_generate_report",
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
1039
|
+
server.tool("baasix_generate_report", `Run an aggregate/analytics query on a database table. ALWAYS use this tool (not baasix_list_items) when the user asks for sums, totals, counts, averages, min/max, grouped data, dashboards, reports, or any analytics/summary query.
|
|
1040
|
+
|
|
1041
|
+
QUERY OPTIONS:
|
|
1042
|
+
- fields: Columns to return. Default ["*"]. Use dot notation for relations: ["category.name", "status"].
|
|
1043
|
+
- filter: Row filter (same operators as baasix_list_items). Applied BEFORE aggregation.
|
|
1044
|
+
- sort: Sort results, e.g. ["count:desc", "name:asc"].
|
|
1045
|
+
- limit: Max rows returned. Default -1 (all).
|
|
1046
|
+
- page: Page number (works with limit).
|
|
1047
|
+
- aggregate: Aggregation functions. Each key is an alias, value is {function, field}.
|
|
1048
|
+
Functions: "count", "sum", "avg", "min", "max"
|
|
1049
|
+
Field: column name or "*" for count.
|
|
1050
|
+
- groupBy: Array of columns to group by. Required when using aggregate.
|
|
1051
|
+
|
|
1052
|
+
EXAMPLES:
|
|
1053
|
+
|
|
1054
|
+
1. Count orders grouped by status:
|
|
1055
|
+
collection: "orders"
|
|
1056
|
+
groupBy: ["status"]
|
|
1057
|
+
aggregate: {"count": {"function": "count", "field": "*"}}
|
|
1058
|
+
|
|
1059
|
+
2. Average price per category, only active products:
|
|
1060
|
+
collection: "products"
|
|
1061
|
+
groupBy: ["category"]
|
|
1062
|
+
aggregate: {"avg_price": {"function": "avg", "field": "price"}, "count": {"function": "count", "field": "*"}}
|
|
1063
|
+
filter: {"status": {"eq": "active"}}
|
|
1064
|
+
|
|
1065
|
+
3. Total revenue by month (raw data for client-side grouping):
|
|
1066
|
+
collection: "orders"
|
|
1067
|
+
fields: ["createdAt", "total"]
|
|
1068
|
+
filter: {"createdAt": {"gte": "2026-01-01"}}
|
|
1069
|
+
sort: ["createdAt:asc"]
|
|
1070
|
+
|
|
1071
|
+
4. Sum of amounts per user with relational field:
|
|
1072
|
+
collection: "payments"
|
|
1073
|
+
groupBy: ["user.name"]
|
|
1074
|
+
aggregate: {"total": {"function": "sum", "field": "amount"}}
|
|
1075
|
+
fields: ["user.name"]
|
|
1076
|
+
|
|
1077
|
+
5. Min/max price:
|
|
1078
|
+
collection: "products"
|
|
1079
|
+
aggregate: {"min_price": {"function": "min", "field": "price"}, "max_price": {"function": "max", "field": "price"}}`, {
|
|
1080
|
+
collection: z.string().describe("Table/collection name to report on"),
|
|
1081
|
+
fields: z.array(z.string()).optional().describe('Columns to return. Default ["*"]. Use dot notation for relations: ["category.name"].'),
|
|
1082
|
+
filter: z.record(z.any()).optional().describe("Row filter applied before aggregation. Same operators as baasix_list_items."),
|
|
1083
|
+
sort: z.array(z.string()).optional().describe('Sort results. Format: ["field:direction"]. E.g. ["count:desc", "name:asc"].'),
|
|
1084
|
+
limit: z.number().optional().describe("Max rows returned. Default -1 (all). Set to limit grouped results."),
|
|
1085
|
+
page: z.number().optional().describe("Page number for pagination (works with limit)."),
|
|
1086
|
+
aggregate: z.record(z.object({
|
|
1087
|
+
function: z.enum(["count", "sum", "avg", "min", "max"]).describe("Aggregation function"),
|
|
1088
|
+
field: z.string().describe('Column to aggregate, or "*" for count'),
|
|
1089
|
+
})).optional().describe("Aggregation definitions. Each key is the result alias."),
|
|
1090
|
+
groupBy: z.array(z.string()).optional().describe("Columns to group by. Required when using aggregate."),
|
|
920
1091
|
}, async (args, extra) => {
|
|
921
|
-
const { collection,
|
|
1092
|
+
const { collection, fields, filter, sort, limit, page, aggregate, groupBy } = args;
|
|
922
1093
|
try {
|
|
923
|
-
const
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
// Build filter with date range
|
|
927
|
-
const filters = [];
|
|
1094
|
+
const body = {};
|
|
1095
|
+
if (fields)
|
|
1096
|
+
body.fields = fields;
|
|
928
1097
|
if (filter)
|
|
929
|
-
|
|
930
|
-
if (
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
}
|
|
937
|
-
}
|
|
938
|
-
if (filters.length > 0) {
|
|
939
|
-
query.filter = filters.length === 1 ? filters[0] : { AND: filters };
|
|
940
|
-
}
|
|
941
|
-
// Add aggregation if groupBy is specified
|
|
942
|
-
if (groupBy) {
|
|
943
|
-
query.groupBy = [groupBy];
|
|
944
|
-
query.aggregate = {
|
|
945
|
-
count: { function: "count", field: "*" },
|
|
946
|
-
};
|
|
1098
|
+
body.filter = filter;
|
|
1099
|
+
if (sort) {
|
|
1100
|
+
// Convert "field:desc" → "-field", "field:asc" → "field" for ItemsService
|
|
1101
|
+
body.sort = sort.map(s => {
|
|
1102
|
+
const [field, dir] = s.split(':');
|
|
1103
|
+
return dir?.toLowerCase() === 'desc' ? `-${field}` : field;
|
|
1104
|
+
});
|
|
947
1105
|
}
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
1106
|
+
if (limit !== undefined)
|
|
1107
|
+
body.limit = limit;
|
|
1108
|
+
if (page !== undefined)
|
|
1109
|
+
body.page = page;
|
|
1110
|
+
if (aggregate)
|
|
1111
|
+
body.aggregate = aggregate;
|
|
1112
|
+
if (groupBy)
|
|
1113
|
+
body.groupBy = groupBy;
|
|
1114
|
+
const res = await callRoute('POST', `/reports/${encodeURIComponent(collection)}`, extra, body);
|
|
1115
|
+
if (!res.ok)
|
|
1116
|
+
return errorResult(res.error || `Failed to generate report for '${collection}'`);
|
|
1117
|
+
return successResult(res.data);
|
|
955
1118
|
}
|
|
956
1119
|
catch (error) {
|
|
957
1120
|
return errorResult(error);
|
|
958
1121
|
}
|
|
959
1122
|
});
|
|
960
1123
|
// ==================== Collection Stats Tool ====================
|
|
961
|
-
server.tool("baasix_collection_stats",
|
|
962
|
-
|
|
963
|
-
|
|
1124
|
+
server.tool("baasix_collection_stats", `Run multiple aggregate queries across different tables in a single call. Each query uses the full report engine (filter, aggregate, groupBy, etc.).
|
|
1125
|
+
|
|
1126
|
+
Each stats entry requires:
|
|
1127
|
+
- name: A unique label for this stat in the results (e.g. "total_orders", "active_users")
|
|
1128
|
+
- collection: The table to query
|
|
1129
|
+
- query: Full report query object with fields, filter, aggregate, groupBy, sort, limit, page
|
|
1130
|
+
|
|
1131
|
+
AGGREGATE FUNCTIONS: "count", "sum", "avg", "min", "max"
|
|
1132
|
+
|
|
1133
|
+
EXAMPLES:
|
|
1134
|
+
|
|
1135
|
+
1. Get total counts for two tables:
|
|
1136
|
+
stats: [
|
|
1137
|
+
{"name": "total_products", "collection": "products", "query": {"aggregate": {"total": {"function": "count", "field": "*"}}}},
|
|
1138
|
+
{"name": "total_orders", "collection": "orders", "query": {"aggregate": {"total": {"function": "count", "field": "*"}}}}
|
|
1139
|
+
]
|
|
1140
|
+
|
|
1141
|
+
2. Count active users + total revenue:
|
|
1142
|
+
stats: [
|
|
1143
|
+
{"name": "active_users", "collection": "users", "query": {"filter": {"status": {"eq": "active"}}, "aggregate": {"count": {"function": "count", "field": "*"}}}},
|
|
1144
|
+
{"name": "total_revenue", "collection": "orders", "query": {"aggregate": {"revenue": {"function": "sum", "field": "total"}}}}
|
|
1145
|
+
]
|
|
1146
|
+
|
|
1147
|
+
3. Recent signups (last 7 days):
|
|
1148
|
+
stats: [
|
|
1149
|
+
{"name": "recent_signups", "collection": "users", "query": {"filter": {"createdAt": {"gte": "$NOW-DAYS_7"}}, "aggregate": {"count": {"function": "count", "field": "*"}}}}
|
|
1150
|
+
]`, {
|
|
1151
|
+
stats: z.array(z.object({
|
|
1152
|
+
name: z.string().describe("Unique label for this stat in the results (e.g. 'total_orders')"),
|
|
1153
|
+
collection: z.string().describe("Table/collection to query"),
|
|
1154
|
+
query: z.record(z.any()).describe("Report query: {fields?, filter?, sort?, limit?, page?, aggregate?, groupBy?}. See baasix_generate_report for full options."),
|
|
1155
|
+
})).describe("Array of stat queries to run across tables"),
|
|
964
1156
|
}, async (args, extra) => {
|
|
965
|
-
const {
|
|
1157
|
+
const { stats } = args;
|
|
966
1158
|
try {
|
|
967
|
-
const
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
const stats = {};
|
|
972
|
-
for (const collection of targetCollections) {
|
|
973
|
-
try {
|
|
974
|
-
const itemsService = new ItemsService(collection, { accountability });
|
|
975
|
-
// Get total count
|
|
976
|
-
const countResult = await itemsService.readByQuery({
|
|
977
|
-
aggregate: { total: { function: "count", field: "*" } },
|
|
978
|
-
limit: 1,
|
|
979
|
-
});
|
|
980
|
-
// Get recent count if timeframe specified
|
|
981
|
-
let recentCount = null;
|
|
982
|
-
if (timeframe) {
|
|
983
|
-
const now = new Date();
|
|
984
|
-
const startDate = new Date();
|
|
985
|
-
if (timeframe === "24h")
|
|
986
|
-
startDate.setHours(now.getHours() - 24);
|
|
987
|
-
else if (timeframe === "7d")
|
|
988
|
-
startDate.setDate(now.getDate() - 7);
|
|
989
|
-
else if (timeframe === "30d")
|
|
990
|
-
startDate.setDate(now.getDate() - 30);
|
|
991
|
-
const recentResult = await itemsService.readByQuery({
|
|
992
|
-
filter: { createdAt: { gte: startDate.toISOString() } },
|
|
993
|
-
aggregate: { recent: { function: "count", field: "*" } },
|
|
994
|
-
limit: 1,
|
|
995
|
-
});
|
|
996
|
-
recentCount = recentResult.data?.[0]?.recent || 0;
|
|
997
|
-
}
|
|
998
|
-
stats[collection] = {
|
|
999
|
-
totalCount: countResult.data?.[0]?.total || 0,
|
|
1000
|
-
...(recentCount !== null && { recentCount, timeframe }),
|
|
1001
|
-
};
|
|
1002
|
-
}
|
|
1003
|
-
catch {
|
|
1004
|
-
stats[collection] = { error: "Could not fetch stats" };
|
|
1005
|
-
}
|
|
1006
|
-
}
|
|
1007
|
-
return successResult(stats);
|
|
1159
|
+
const res = await callRoute('POST', '/reports/stats', extra, { stats });
|
|
1160
|
+
if (!res.ok)
|
|
1161
|
+
return errorResult(res.error || 'Failed to generate stats');
|
|
1162
|
+
return successResult(res.data);
|
|
1008
1163
|
}
|
|
1009
1164
|
catch (error) {
|
|
1010
1165
|
return errorResult(error);
|
|
1011
1166
|
}
|
|
1012
1167
|
});
|
|
1013
1168
|
// ==================== Send Notification Tool ====================
|
|
1014
|
-
server.tool("baasix_send_notification", "Send
|
|
1015
|
-
recipients: z.array(z.string()).describe("Array of user
|
|
1169
|
+
server.tool("baasix_send_notification", "Send an in-app notification to one or more users by their user UUIDs. Notifications appear in the user's notification list and can be marked as seen.", {
|
|
1170
|
+
recipients: z.array(z.string()).describe("Array of user UUIDs to notify"),
|
|
1016
1171
|
title: z.string().describe("Notification title"),
|
|
1017
|
-
message: z.string().describe("Notification message"),
|
|
1018
|
-
type: z.string().optional().default("info").describe("Notification type"),
|
|
1172
|
+
message: z.string().describe("Notification message body"),
|
|
1173
|
+
type: z.string().optional().default("info").describe("Notification type: 'info', 'warning', 'error', 'success'"),
|
|
1019
1174
|
}, async (args, extra) => {
|
|
1020
1175
|
const { recipients, title, message, type } = args;
|
|
1021
1176
|
try {
|
|
1022
|
-
const
|
|
1023
|
-
|
|
1024
|
-
const notifications = await Promise.all(recipients.map((userId) => itemsService.createOne({
|
|
1025
|
-
user_Id: userId,
|
|
1177
|
+
const res = await callRoute('POST', '/notifications/send', extra, {
|
|
1178
|
+
type: type || "info",
|
|
1026
1179
|
title,
|
|
1027
1180
|
message,
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1181
|
+
userIds: recipients,
|
|
1182
|
+
});
|
|
1183
|
+
if (!res.ok)
|
|
1184
|
+
return errorResult(res.error || 'Failed to send notifications');
|
|
1031
1185
|
return successResult({
|
|
1032
1186
|
success: true,
|
|
1033
|
-
sent:
|
|
1034
|
-
|
|
1187
|
+
sent: recipients.length,
|
|
1188
|
+
notificationIds: res.data?.notificationIds || res.data,
|
|
1035
1189
|
});
|
|
1036
1190
|
}
|
|
1037
1191
|
catch (error) {
|
|
@@ -1039,47 +1193,52 @@ AVAILABLE VARIABLES:
|
|
|
1039
1193
|
}
|
|
1040
1194
|
});
|
|
1041
1195
|
// ==================== Get Permission Tool ====================
|
|
1042
|
-
server.tool("baasix_get_permission", "Get a specific permission by
|
|
1043
|
-
id: z.string().describe("Permission
|
|
1196
|
+
server.tool("baasix_get_permission", "Get a specific permission rule by its UUID. Returns the full permission object including role_Id, collection, action, fields, conditions, defaultValues, and relConditions.", {
|
|
1197
|
+
id: z.string().describe("Permission UUID"),
|
|
1044
1198
|
}, async (args, extra) => {
|
|
1045
1199
|
const { id } = args;
|
|
1046
1200
|
try {
|
|
1047
|
-
const
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
return successResult(
|
|
1201
|
+
const res = await callRoute('GET', `/permissions/${encodeURIComponent(id)}`, extra);
|
|
1202
|
+
if (!res.ok)
|
|
1203
|
+
return errorResult(res.error || `Permission '${id}' not found`);
|
|
1204
|
+
return successResult(res.data?.data || res.data);
|
|
1051
1205
|
}
|
|
1052
1206
|
catch (error) {
|
|
1053
1207
|
return errorResult(error);
|
|
1054
1208
|
}
|
|
1055
1209
|
});
|
|
1056
1210
|
// ==================== Get Permissions for Role Tool ====================
|
|
1057
|
-
server.tool("baasix_get_permissions",
|
|
1058
|
-
|
|
1211
|
+
server.tool("baasix_get_permissions", `Get all permission rules assigned to a specific role. Shows which tables the role can access and what actions (create/read/update/delete) are allowed, including any row-level security conditions, field restrictions, default values, and relationship conditions.
|
|
1212
|
+
|
|
1213
|
+
Accepts either the role name (e.g., "editor", "public") or the role UUID.`, {
|
|
1214
|
+
role: z.string().describe("Role name (e.g., 'editor', 'public') or role UUID"),
|
|
1059
1215
|
}, async (args, extra) => {
|
|
1060
1216
|
const { role } = args;
|
|
1061
1217
|
try {
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1218
|
+
// Find role by name or id
|
|
1219
|
+
const roleParams = new URLSearchParams();
|
|
1220
|
+
roleParams.append('filter', JSON.stringify({ OR: [{ name: { eq: role } }, { id: { eq: role } }] }));
|
|
1221
|
+
roleParams.append('limit', '1');
|
|
1222
|
+
const rolesRes = await callRoute('GET', `/items/baasix_Role?${roleParams}`, extra);
|
|
1223
|
+
if (!rolesRes.ok)
|
|
1224
|
+
return errorResult(rolesRes.error || `Failed to look up role '${role}'`);
|
|
1225
|
+
const rolesData = rolesRes.data?.data || rolesRes.data;
|
|
1226
|
+
if (!Array.isArray(rolesData) || !rolesData.length) {
|
|
1070
1227
|
return errorResult(`Role '${role}' not found`);
|
|
1071
1228
|
}
|
|
1072
|
-
const roleId =
|
|
1229
|
+
const roleId = rolesData[0].id;
|
|
1073
1230
|
// Get permissions for this role
|
|
1074
|
-
const
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1231
|
+
const permParams = new URLSearchParams();
|
|
1232
|
+
permParams.append('filter', JSON.stringify({ role_Id: { eq: roleId } }));
|
|
1233
|
+
permParams.append('limit', '-1');
|
|
1234
|
+
const permRes = await callRoute('GET', `/permissions?${permParams}`, extra);
|
|
1235
|
+
if (!permRes.ok)
|
|
1236
|
+
return errorResult(permRes.error || 'Failed to get permissions');
|
|
1237
|
+
const permData = permRes.data?.data || permRes.data;
|
|
1079
1238
|
return successResult({
|
|
1080
|
-
role:
|
|
1081
|
-
permissions:
|
|
1082
|
-
totalCount:
|
|
1239
|
+
role: rolesData[0],
|
|
1240
|
+
permissions: Array.isArray(permData) ? permData : [],
|
|
1241
|
+
totalCount: Array.isArray(permData) ? permData.length : 0,
|
|
1083
1242
|
});
|
|
1084
1243
|
}
|
|
1085
1244
|
catch (error) {
|
|
@@ -1087,51 +1246,69 @@ AVAILABLE VARIABLES:
|
|
|
1087
1246
|
}
|
|
1088
1247
|
});
|
|
1089
1248
|
// ==================== Update Permissions for Role Tool ====================
|
|
1090
|
-
server.tool("baasix_update_permissions",
|
|
1091
|
-
|
|
1249
|
+
server.tool("baasix_update_permissions", `Bulk set/update access control permissions for a role — define which tables a role can create, read, update, or delete. Creates new permissions or updates existing ones. Automatically reloads the permission cache.
|
|
1250
|
+
|
|
1251
|
+
Accepts role name (e.g., "editor") or role UUID. Each permission in the array specifies a table + action combination.
|
|
1252
|
+
If a permission for that role+table+action already exists, it is updated. Otherwise, a new one is created.
|
|
1253
|
+
Fields default to ["*"] (all columns) if omitted.
|
|
1254
|
+
|
|
1255
|
+
EXAMPLE — Give "editor" full CRUD on products, read-only on categories:
|
|
1256
|
+
role: "editor"
|
|
1257
|
+
permissions: [
|
|
1258
|
+
{"collection": "products", "action": "create", "fields": ["*"]},
|
|
1259
|
+
{"collection": "products", "action": "read", "fields": ["*"]},
|
|
1260
|
+
{"collection": "products", "action": "update", "fields": ["name", "price"]},
|
|
1261
|
+
{"collection": "products", "action": "delete"},
|
|
1262
|
+
{"collection": "categories", "action": "read", "fields": ["*"]}
|
|
1263
|
+
]
|
|
1264
|
+
|
|
1265
|
+
With row-level security:
|
|
1266
|
+
permissions: [{"collection": "posts", "action": "read", "conditions": {"author_Id": {"eq": "$CURRENT_USER"}}}]`, {
|
|
1267
|
+
role: z.string().describe("Role name (e.g., 'editor', 'public') or role UUID"),
|
|
1092
1268
|
permissions: z
|
|
1093
1269
|
.array(z.object({
|
|
1094
|
-
collection: z.string(),
|
|
1095
|
-
action: z.enum(["create", "read", "update", "delete"]),
|
|
1096
|
-
fields: z.array(z.string()).optional(),
|
|
1097
|
-
conditions: z.record(z.any()).optional(),
|
|
1270
|
+
collection: z.string().describe("Table/collection name"),
|
|
1271
|
+
action: z.enum(["create", "read", "update", "delete"]).describe("CRUD action to allow"),
|
|
1272
|
+
fields: z.array(z.string()).optional().describe("Allowed columns: [\"*\"] for all, or specific names. Defaults to [\"*\"]."),
|
|
1273
|
+
conditions: z.record(z.any()).optional().describe("Row-level security conditions (same operators as filter). Supports $CURRENT_USER, $NOW."),
|
|
1098
1274
|
}))
|
|
1099
|
-
.describe("Array of permission
|
|
1275
|
+
.describe("Array of permission rules to create or update for this role"),
|
|
1100
1276
|
}, async (args, extra) => {
|
|
1101
1277
|
const { role, permissions } = args;
|
|
1102
1278
|
try {
|
|
1103
|
-
const accountability = getAccountability(extra);
|
|
1104
1279
|
// Find role
|
|
1105
|
-
const
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1280
|
+
const roleParams = new URLSearchParams();
|
|
1281
|
+
roleParams.append('filter', JSON.stringify({ OR: [{ name: { eq: role } }, { id: { eq: role } }] }));
|
|
1282
|
+
roleParams.append('limit', '1');
|
|
1283
|
+
const rolesRes = await callRoute('GET', `/items/baasix_Role?${roleParams}`, extra);
|
|
1284
|
+
if (!rolesRes.ok)
|
|
1285
|
+
return errorResult(rolesRes.error || `Failed to look up role '${role}'`);
|
|
1286
|
+
const rolesData = rolesRes.data?.data || rolesRes.data;
|
|
1287
|
+
if (!Array.isArray(rolesData) || !rolesData.length) {
|
|
1111
1288
|
return errorResult(`Role '${role}' not found`);
|
|
1112
1289
|
}
|
|
1113
|
-
const roleData =
|
|
1290
|
+
const roleData = rolesData[0];
|
|
1114
1291
|
const roleId = roleData.id;
|
|
1115
|
-
const permissionsService = new ItemsService("baasix_Permission", { accountability });
|
|
1116
1292
|
// Create/update permissions
|
|
1117
1293
|
const results = await Promise.all(permissions.map(async (perm) => {
|
|
1118
|
-
// Check if permission exists
|
|
1119
|
-
const
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
});
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1294
|
+
// Check if permission exists for this role+collection+action
|
|
1295
|
+
const existParams = new URLSearchParams();
|
|
1296
|
+
existParams.append('filter', JSON.stringify({
|
|
1297
|
+
AND: [{ role_Id: { eq: roleId } }, { collection: { eq: perm.collection } }, { action: { eq: perm.action } }],
|
|
1298
|
+
}));
|
|
1299
|
+
existParams.append('limit', '1');
|
|
1300
|
+
const existRes = await callRoute('GET', `/permissions?${existParams}`, extra);
|
|
1301
|
+
const existData = existRes.data?.data || existRes.data;
|
|
1302
|
+
if (Array.isArray(existData) && existData.length) {
|
|
1303
|
+
// Update existing via PATCH /permissions/:id (route reloads cache)
|
|
1304
|
+
return callRoute('PATCH', `/permissions/${encodeURIComponent(existData[0].id)}`, extra, {
|
|
1128
1305
|
fields: perm.fields || ["*"],
|
|
1129
1306
|
conditions: perm.conditions || {},
|
|
1130
1307
|
});
|
|
1131
1308
|
}
|
|
1132
1309
|
else {
|
|
1133
|
-
// Create new
|
|
1134
|
-
return
|
|
1310
|
+
// Create new via POST /permissions (route reloads cache)
|
|
1311
|
+
return callRoute('POST', '/permissions', extra, {
|
|
1135
1312
|
role_Id: roleId,
|
|
1136
1313
|
collection: perm.collection,
|
|
1137
1314
|
action: perm.action,
|
|
@@ -1140,8 +1317,8 @@ AVAILABLE VARIABLES:
|
|
|
1140
1317
|
});
|
|
1141
1318
|
}
|
|
1142
1319
|
}));
|
|
1143
|
-
// Reload permissions cache
|
|
1144
|
-
await
|
|
1320
|
+
// Reload permissions cache explicitly
|
|
1321
|
+
await callRoute('POST', '/permissions/reload', extra);
|
|
1145
1322
|
return successResult({
|
|
1146
1323
|
success: true,
|
|
1147
1324
|
role: roleData.name,
|
|
@@ -1153,8 +1330,15 @@ AVAILABLE VARIABLES:
|
|
|
1153
1330
|
}
|
|
1154
1331
|
});
|
|
1155
1332
|
// ==================== Auth Tools ====================
|
|
1156
|
-
server.tool("baasix_get_current_user",
|
|
1157
|
-
|
|
1333
|
+
server.tool("baasix_get_current_user", `Get the currently authenticated user's profile, role, and permissions.
|
|
1334
|
+
|
|
1335
|
+
Use fields to include related data:
|
|
1336
|
+
- ["*"] → all user fields
|
|
1337
|
+
- ["*", "role.*"] → user fields + full role object
|
|
1338
|
+
- ["email", "firstName", "role.name"] → specific fields
|
|
1339
|
+
|
|
1340
|
+
Returns authenticated: false if no user is logged in (public/anonymous access).`, {
|
|
1341
|
+
fields: z.array(z.string()).optional().describe("Fields to return. Default: [\"*\", \"role.*\"]. Use dot notation for relations."),
|
|
1158
1342
|
}, async (args, extra) => {
|
|
1159
1343
|
const { fields } = args;
|
|
1160
1344
|
try {
|
|
@@ -1163,18 +1347,24 @@ AVAILABLE VARIABLES:
|
|
|
1163
1347
|
return successResult({
|
|
1164
1348
|
authenticated: false,
|
|
1165
1349
|
role: accountability.role,
|
|
1166
|
-
admin:
|
|
1350
|
+
admin: false,
|
|
1351
|
+
});
|
|
1352
|
+
}
|
|
1353
|
+
// Call /auth/me which returns user, role, permissions, tenant
|
|
1354
|
+
const res = await callRoute('GET', '/auth/me', extra);
|
|
1355
|
+
if (!res.ok) {
|
|
1356
|
+
// If 401, user is not authenticated
|
|
1357
|
+
return successResult({
|
|
1358
|
+
authenticated: false,
|
|
1359
|
+
role: accountability.role,
|
|
1360
|
+
admin: false,
|
|
1167
1361
|
});
|
|
1168
1362
|
}
|
|
1169
|
-
const usersService = new ItemsService("baasix_User", { accountability });
|
|
1170
|
-
const user = await usersService.readOne(accountability.user, {
|
|
1171
|
-
fields: fields || ["*", "role.*"],
|
|
1172
|
-
});
|
|
1173
1363
|
return successResult({
|
|
1174
1364
|
authenticated: true,
|
|
1175
|
-
user,
|
|
1176
|
-
role: accountability.role,
|
|
1177
|
-
admin: accountability.
|
|
1365
|
+
user: res.data?.user || res.data,
|
|
1366
|
+
role: res.data?.role || accountability.role,
|
|
1367
|
+
admin: accountability.user?.isAdmin || false,
|
|
1178
1368
|
});
|
|
1179
1369
|
}
|
|
1180
1370
|
catch (error) {
|
|
@@ -1299,14 +1489,14 @@ AVAILABLE VARIABLES:
|
|
|
1299
1489
|
}
|
|
1300
1490
|
});
|
|
1301
1491
|
// ==================== Auth Status & Session Tools ====================
|
|
1302
|
-
server.tool("baasix_auth_status", "Check
|
|
1492
|
+
server.tool("baasix_auth_status", "Check if the current MCP session is authenticated, and show the current user, role, and admin status.", {}, async (_args, extra) => {
|
|
1303
1493
|
try {
|
|
1304
1494
|
const accountability = getAccountability(extra);
|
|
1305
1495
|
return successResult({
|
|
1306
1496
|
authenticated: !!accountability.user,
|
|
1307
|
-
userId: accountability.user,
|
|
1497
|
+
userId: accountability.user?.id || null,
|
|
1308
1498
|
role: accountability.role,
|
|
1309
|
-
admin: accountability.
|
|
1499
|
+
admin: accountability.user?.isAdmin || false,
|
|
1310
1500
|
sessionId: extra.sessionId || null,
|
|
1311
1501
|
});
|
|
1312
1502
|
}
|
|
@@ -1314,60 +1504,90 @@ AVAILABLE VARIABLES:
|
|
|
1314
1504
|
return errorResult(error);
|
|
1315
1505
|
}
|
|
1316
1506
|
});
|
|
1317
|
-
server.tool("baasix_login", "
|
|
1507
|
+
server.tool("baasix_login", "Authenticate with email and password to get admin or role-based access. Required before performing write operations if not already authenticated via headers.", {
|
|
1318
1508
|
email: z.string().email().describe("User email address"),
|
|
1319
1509
|
password: z.string().describe("User password"),
|
|
1320
|
-
}, async (args,
|
|
1510
|
+
}, async (args, extra) => {
|
|
1321
1511
|
const { email, password } = args;
|
|
1322
1512
|
try {
|
|
1323
1513
|
const { default: axios } = await import("axios");
|
|
1324
1514
|
const baseUrl = `http://localhost:${env.get("PORT") || "8056"}`;
|
|
1325
|
-
const response = await axios.post(`${baseUrl}/auth/login`, {
|
|
1326
|
-
|
|
1327
|
-
|
|
1515
|
+
const response = await axios.post(`${baseUrl}/auth/login`, { email, password });
|
|
1516
|
+
const data = response.data;
|
|
1517
|
+
if (!data || !data.user) {
|
|
1518
|
+
return errorResult("Login failed: invalid credentials");
|
|
1519
|
+
}
|
|
1520
|
+
// Build accountability from login response and store in session
|
|
1521
|
+
const isAdmin = data.role?.name === "administrator";
|
|
1522
|
+
const accountability = {
|
|
1523
|
+
user: {
|
|
1524
|
+
id: data.user.id,
|
|
1525
|
+
email: data.user.email,
|
|
1526
|
+
firstName: data.user.firstName,
|
|
1527
|
+
lastName: data.user.lastName,
|
|
1528
|
+
isAdmin,
|
|
1529
|
+
role: data.role?.name || "public",
|
|
1530
|
+
},
|
|
1531
|
+
role: data.role ? {
|
|
1532
|
+
id: data.role.id,
|
|
1533
|
+
name: data.role.name,
|
|
1534
|
+
isTenantSpecific: data.role.isTenantSpecific,
|
|
1535
|
+
} : null,
|
|
1536
|
+
permissions: data.permissions || [],
|
|
1537
|
+
tenant: data.tenant?.id || null,
|
|
1538
|
+
ipaddress: "127.0.0.1",
|
|
1539
|
+
token: data.token,
|
|
1540
|
+
};
|
|
1541
|
+
// Update the MCP session with the new accountability
|
|
1542
|
+
if (extra.sessionId) {
|
|
1543
|
+
setMCPSession(extra.sessionId, accountability);
|
|
1544
|
+
}
|
|
1545
|
+
return successResult({
|
|
1546
|
+
token: data.token,
|
|
1547
|
+
user: data.user,
|
|
1548
|
+
role: data.role,
|
|
1549
|
+
message: "Login successful. Session accountability updated.",
|
|
1328
1550
|
});
|
|
1329
|
-
return successResult(response.data);
|
|
1330
1551
|
}
|
|
1331
1552
|
catch (error) {
|
|
1332
1553
|
const axiosError = error;
|
|
1333
|
-
return errorResult(axiosError.response?.data?.message || axiosError.message || "
|
|
1554
|
+
return errorResult(axiosError.response?.data?.message || axiosError.message || "Login failed");
|
|
1334
1555
|
}
|
|
1335
1556
|
});
|
|
1336
1557
|
server.tool("baasix_logout", "Logout and invalidate current session", {}, async (_args, extra) => {
|
|
1337
1558
|
try {
|
|
1338
|
-
const { default: axios } = await import("axios");
|
|
1339
|
-
const baseUrl = `http://localhost:${env.get("PORT") || "8056"}`;
|
|
1340
|
-
await axios.post(`${baseUrl}/auth/logout`);
|
|
1341
1559
|
// Remove MCP session if exists
|
|
1342
1560
|
if (extra.sessionId) {
|
|
1343
1561
|
removeMCPSession(extra.sessionId);
|
|
1344
1562
|
}
|
|
1345
|
-
return successResult({ success: true, message: "Logged out successfully" });
|
|
1563
|
+
return successResult({ success: true, message: "Logged out successfully. Session cleared." });
|
|
1346
1564
|
}
|
|
1347
1565
|
catch (error) {
|
|
1348
|
-
const
|
|
1349
|
-
return errorResult(
|
|
1566
|
+
const err = error;
|
|
1567
|
+
return errorResult(err.message || "Logout failed");
|
|
1350
1568
|
}
|
|
1351
1569
|
});
|
|
1352
1570
|
server.tool("baasix_refresh_auth", "Refresh authentication token", {
|
|
1353
1571
|
refreshToken: z.string().optional().describe("Refresh token (if not using cookies)"),
|
|
1354
|
-
}, async (
|
|
1355
|
-
const { refreshToken } = args;
|
|
1572
|
+
}, async (_args, extra) => {
|
|
1356
1573
|
try {
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
const
|
|
1360
|
-
|
|
1574
|
+
// In MCP context, the session is already maintained via session IDs.
|
|
1575
|
+
// Refresh is not needed since accountability is stored per-session.
|
|
1576
|
+
const accountability = getAccountability(extra);
|
|
1577
|
+
return successResult({
|
|
1578
|
+
authenticated: !!accountability.user,
|
|
1579
|
+
userId: accountability.user?.id || null,
|
|
1580
|
+
role: accountability.role,
|
|
1581
|
+
message: "MCP sessions are managed internally. Use baasix_login to re-authenticate if needed.",
|
|
1361
1582
|
});
|
|
1362
|
-
return successResult(response.data);
|
|
1363
1583
|
}
|
|
1364
1584
|
catch (error) {
|
|
1365
|
-
const
|
|
1366
|
-
return errorResult(
|
|
1585
|
+
const err = error;
|
|
1586
|
+
return errorResult(err.message || "Refresh failed");
|
|
1367
1587
|
}
|
|
1368
1588
|
});
|
|
1369
1589
|
// ==================== Update Relationship Tool ====================
|
|
1370
|
-
server.tool("baasix_update_relationship", "
|
|
1590
|
+
server.tool("baasix_update_relationship", "Modify an existing foreign key / relationship between two database tables (change delete behavior, alias, etc.).", {
|
|
1371
1591
|
sourceCollection: z.string().describe("Source collection name"),
|
|
1372
1592
|
relationshipName: z.string().describe("Relationship field name to update"),
|
|
1373
1593
|
relationshipData: z
|
|
@@ -1378,34 +1598,16 @@ AVAILABLE VARIABLES:
|
|
|
1378
1598
|
description: z.string().optional().describe("Relationship description"),
|
|
1379
1599
|
})
|
|
1380
1600
|
.describe("Updated relationship configuration"),
|
|
1381
|
-
}, async (args,
|
|
1601
|
+
}, async (args, extra) => {
|
|
1382
1602
|
const { sourceCollection, relationshipName, relationshipData } = args;
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
return errorResult(`Relationship '${relationshipName}' not found in collection '${sourceCollection}'`);
|
|
1392
|
-
}
|
|
1393
|
-
// Update the relationship
|
|
1394
|
-
const updatedRelationship = { ...relationship, ...relationshipData };
|
|
1395
|
-
const relationships = schemaWithRelationships.relationships?.map((r) => r.name === relationshipName ? updatedRelationship : r) || [];
|
|
1396
|
-
await schemaManager.updateSchema(sourceCollection, {
|
|
1397
|
-
...schema,
|
|
1398
|
-
relationships,
|
|
1399
|
-
});
|
|
1400
|
-
return successResult({
|
|
1401
|
-
success: true,
|
|
1402
|
-
collection: sourceCollection,
|
|
1403
|
-
relationship: updatedRelationship,
|
|
1404
|
-
});
|
|
1405
|
-
}
|
|
1406
|
-
catch (error) {
|
|
1407
|
-
return errorResult(error);
|
|
1408
|
-
}
|
|
1603
|
+
const res = await callRoute('PATCH', `/schemas/${encodeURIComponent(sourceCollection)}/relationships/${encodeURIComponent(relationshipName)}`, extra, relationshipData);
|
|
1604
|
+
if (!res.ok)
|
|
1605
|
+
return errorResult(res.error || `Failed to update relationship '${relationshipName}' on '${sourceCollection}'`);
|
|
1606
|
+
return successResult({
|
|
1607
|
+
success: true,
|
|
1608
|
+
collection: sourceCollection,
|
|
1609
|
+
relationship: res.data,
|
|
1610
|
+
});
|
|
1409
1611
|
});
|
|
1410
1612
|
return server;
|
|
1411
1613
|
}
|