@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.
@@ -2,8 +2,9 @@
2
2
  /**
3
3
  * MCPService - Model Context Protocol Server for Baasix
4
4
  *
5
- * This service provides MCP tools that directly call Baasix services,
6
- * eliminating HTTP round-trips and providing better performance.
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: "administrator",
45
- admin: true,
46
- ip: "127.0.0.1",
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: "baasix-mcp-server",
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
- server.tool("baasix_list_schemas", "Get all available collections/schemas in Baasix with optional search and pagination", {
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 accountability = getAccountability(extra);
102
- const schemaService = new ItemsService("baasix_SchemaDefinition", { accountability });
103
- const query = {
104
- fields: ["collectionName", "schema"],
105
- page,
106
- limit,
107
- };
108
- if (sort && sort.includes(":")) {
109
- const [field, direction] = sort.split(":");
110
- query.sort = [direction?.toLowerCase() === "desc" ? `-${field}` : field];
111
- }
112
- if (search) {
113
- query.search = search;
114
- query.searchFields = ["collectionName"];
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 detailed schema information for a specific collection", {
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, _extra) => {
200
+ }, async (args, extra) => {
127
201
  const { collection } = args;
128
- const schemaDef = schemaManager.getSchemaDefinition(collection);
129
- if (!schemaDef) {
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", `Create a new collection schema in Baasix.
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 with values.length (e.g., 255)
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: With defaultValue.type: "UUIDV4"
148
- - SUID: Short unique ID with defaultValue.type: "SUID"
149
- - JSONB: JSON with indexing
150
- - Array: values.type specifies element type
151
- - Enum: values.values array
152
-
153
- DEFAULT VALUE TYPES:
154
- - { type: "UUIDV4" } - Random UUID v4
155
- - { type: "SUID" } - Short unique ID
156
- - { type: "NOW" } - Current timestamp
157
- - { type: "AUTOINCREMENT" } - Auto-incrementing integer
158
- - { type: "SQL", value: "..." } - Custom SQL expression`, {
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, _extra) => {
239
+ }, async (args, extra) => {
167
240
  const { collection, schema } = args;
168
- try {
169
- await schemaManager.createCollection(collection, schema);
170
- return successResult({
171
- success: true,
172
- message: `Collection '${collection}' created successfully`,
173
- collectionName: collection,
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", "Update an existing collection schema (add/modify/remove fields)", {
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, _extra) => {
258
+ }, async (args, extra) => {
189
259
  const { collection, schema } = args;
190
- try {
191
- await schemaManager.updateCollection(collection, schema);
192
- return successResult({
193
- success: true,
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", "Delete a collection and all its data", {
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, _extra) => {
267
+ }, async (args, extra) => {
204
268
  const { collection } = args;
205
- try {
206
- await schemaManager.deleteCollection(collection);
207
- return successResult({
208
- success: true,
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 an index to a collection for better query performance", {
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, _extra) => {
284
+ }, async (args, extra) => {
227
285
  const { collection, indexDefinition } = args;
228
- try {
229
- await schemaManager.addIndex(collection, indexDefinition);
230
- return successResult({
231
- success: true,
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 an index from a collection", {
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, _extra) => {
294
+ }, async (args, extra) => {
243
295
  const { collection, indexName } = args;
244
- try {
245
- await schemaManager.removeIndex(collection, indexName);
246
- return successResult({
247
- success: true,
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): Creates foreign key with auto-index. products.category → categories
259
- - O2M (One-to-Many): Virtual reverse of M2O. categories.products → products
260
- - O2O (One-to-One): Creates foreign key with auto-index. user.profile → profiles
261
- - M2M (Many-to-Many): Creates junction table with auto-indexed FKs. products ↔ tags
262
- - M2A (Many-to-Any): Polymorphic junction table. comments posts OR products
263
-
264
- EXAMPLE M2O:
265
- {
266
- "name": "category",
267
- "type": "M2O",
268
- "target": "categories",
269
- "alias": "products",
270
- "onDelete": "CASCADE"
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, _extra) => {
334
+ }, async (args, extra) => {
286
335
  const { sourceCollection, relationshipData } = args;
287
- try {
288
- await schemaManager.addRelationship(sourceCollection, relationshipData);
289
- return successResult({
290
- success: true,
291
- message: `Relationship '${relationshipData.name}' created on '${sourceCollection}'`,
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", "Delete a relationship from a collection", {
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, _extra) => {
347
+ }, async (args, extra) => {
302
348
  const { sourceCollection, fieldName } = args;
303
- try {
304
- await schemaManager.removeRelationship(sourceCollection, fieldName);
305
- return successResult({
306
- success: true,
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 schemas as JSON for backup or migration", {}, async (_args, _extra) => {
315
- try {
316
- const schemas = schemaManager.exportSchemas();
317
- return successResult(schemas);
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 schemas from JSON data", {
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, _extra) => {
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 schemaManager.importSchemas(schemas);
329
- return successResult({
330
- success: true,
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 (error) {
335
- return errorResult(error);
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 items from a collection with powerful filtering, sorting, pagination, relations, and aggregation.
340
-
341
- FILTER OPERATORS (50+):
342
- - Comparison: eq, neq, gt, gte, lt, lte
343
- - String: contains, icontains, startswith, endswith, like, ilike, regex
344
- - Null: isNull (true/false), empty (true/false)
345
- - List: in, nin, between, nbetween
346
- - Array: arraycontains, arraycontainsany, arraylength, arrayempty
347
- - JSONB: jsoncontains, jsonhaskey, jsonhasanykeys, jsonhasallkeys, jsonpath
348
- - Geospatial: dwithin, intersects, contains, within, overlaps
349
- - Logical: AND, OR, NOT
350
-
351
- DYNAMIC VARIABLES:
352
- - $CURRENT_USER: Current user's ID
353
- - $NOW: Current timestamp
354
- - $NOW-DAYS_7: 7 days ago
355
- - $NOW+MONTHS_1: 1 month from now
356
-
357
- FILTER EXAMPLES:
358
- - {"status": {"eq": "active"}}
359
- - {"AND": [{"price": {"gte": 10}}, {"price": {"lte": 100}}]}
360
- - {"tags": {"arraycontains": ["featured"]}}`, {
361
- collection: z.string().describe("Collection name"),
362
- filter: z.record(z.any()).optional().describe("Filter criteria"),
363
- fields: z.array(z.string()).optional().describe("Fields to return"),
364
- sort: z.string().optional().describe("Sort field and direction (e.g., 'createdAt:desc')"),
365
- page: z.number().optional().default(1).describe("Page number"),
366
- limit: z.number().optional().default(10).describe("Items per page (-1 for all)"),
367
- search: z.string().optional().describe("Full-text search query"),
368
- searchFields: z.array(z.string()).optional().describe("Fields to search in"),
369
- aggregate: z.record(z.any()).optional().describe("Aggregation functions"),
370
- groupBy: z.array(z.string()).optional().describe("Fields to group by"),
371
- relConditions: z.record(z.any()).optional().describe("Filter conditions for related records"),
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 accountability = getAccountability(extra);
376
- const itemsService = new ItemsService(collection, { accountability });
377
- const query = { page, limit };
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
- query.filter = filter;
494
+ params.set('filter', JSON.stringify(filter));
380
495
  if (fields)
381
- query.fields = fields;
496
+ params.set('fields', JSON.stringify(fields));
382
497
  if (sort) {
383
498
  const [field, direction] = sort.split(":");
384
- query.sort = [direction?.toLowerCase() === "desc" ? `-${field}` : field];
499
+ params.set('sort', JSON.stringify([direction?.toLowerCase() === "desc" ? `-${field}` : field]));
385
500
  }
386
501
  if (search)
387
- query.search = search;
502
+ params.set('search', search);
388
503
  if (searchFields)
389
- query.searchFields = searchFields;
504
+ params.set('searchFields', searchFields.join(','));
390
505
  if (aggregate)
391
- query.aggregate = aggregate;
506
+ params.set('aggregate', JSON.stringify(aggregate));
392
507
  if (groupBy)
393
- query.groupBy = groupBy;
508
+ params.set('groupBy', groupBy.join(','));
394
509
  if (relConditions)
395
- query.relConditions = relConditions;
396
- const result = await itemsService.readByQuery(query);
397
- return successResult({
398
- data: result.data,
399
- totalCount: result.totalCount,
400
- page,
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", "Get a specific item by ID from a collection, optionally including related data", {
409
- collection: z.string().describe("Collection name"),
410
- id: z.string().describe("Item ID (UUID)"),
411
- fields: z.array(z.string()).optional().describe("Fields to return"),
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 accountability = getAccountability(extra);
416
- const itemsService = new ItemsService(collection, { accountability });
417
- const query = {};
535
+ const params = new URLSearchParams();
418
536
  if (fields)
419
- query.fields = fields;
420
- const result = await itemsService.readOne(id, query);
421
- return successResult({ data: result });
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", "Create a new item in a collection", {
428
- collection: z.string().describe("Collection name"),
429
- data: z.record(z.any()).describe("Item data"),
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 accountability = getAccountability(extra);
434
- const itemsService = new ItemsService(collection, { accountability });
435
- const result = await itemsService.createOne(data);
436
- return successResult({ data: result });
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", "Update an existing item in a collection", {
443
- collection: z.string().describe("Collection name"),
444
- id: z.string().describe("Item ID"),
445
- data: z.record(z.any()).describe("Updated item data"),
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 accountability = getAccountability(extra);
450
- const itemsService = new ItemsService(collection, { accountability });
451
- const result = await itemsService.updateOne(id, data);
452
- return successResult({ data: result });
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 an item from a collection", {
459
- collection: z.string().describe("Collection name"),
460
- id: z.string().describe("Item ID"),
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 accountability = getAccountability(extra);
465
- const itemsService = new ItemsService(collection, { accountability });
466
- await itemsService.deleteOne(id);
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", "List files with metadata and optional filtering", {
475
- filter: z.record(z.any()).optional().describe("Filter criteria"),
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 accountability = getAccountability(extra);
482
- const filesService = new ItemsService("baasix_File", { accountability });
483
- const query = { page, limit };
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
- query.filter = filter;
486
- const result = await filesService.readByQuery(query);
487
- return successResult({
488
- data: result.data,
489
- totalCount: result.totalCount,
490
- page,
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 information about a specific file", {
499
- id: z.string().describe("File ID"),
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 accountability = getAccountability(extra);
504
- const filesService = new ItemsService("baasix_File", { accountability });
505
- const result = await filesService.readOne(id);
506
- return successResult({ data: result });
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 a file", {
513
- id: z.string().describe("File ID"),
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 accountability = getAccountability(extra);
518
- const filesService = new ItemsService("baasix_File", { accountability });
519
- await filesService.deleteOne(id);
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 available roles", {}, async (_args, extra) => {
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 accountability = getAccountability(extra);
530
- const rolesService = new ItemsService("baasix_Role", { accountability });
531
- const result = await rolesService.readByQuery({ limit: -1 }, true);
532
- return successResult({ data: result.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", "List all permissions with optional filtering", {
539
- filter: z.record(z.any()).optional().describe("Filter criteria"),
540
- sort: z.string().optional().describe("Sort field and direction"),
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 accountability = getAccountability(extra);
547
- const permService = new ItemsService("baasix_Permission", { accountability });
548
- const query = { page, limit };
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
- query.filter = filter;
694
+ params.set('filter', JSON.stringify(filter));
551
695
  if (sort) {
552
696
  const [field, direction] = sort.split(":");
553
- query.sort = [direction?.toLowerCase() === "desc" ? `-${field}` : field];
697
+ params.set('sort', JSON.stringify([direction?.toLowerCase() === "desc" ? `-${field}` : field]));
554
698
  }
555
- const result = await permService.readByQuery(query, true);
556
- return successResult({
557
- data: result.data,
558
- totalCount: result.totalCount,
559
- page,
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", `Create a new permission for role-based access control.
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
- ACTIONS: create, read, update, delete
711
+ First use baasix_list_roles to get the role UUID, then create permissions for that role.
570
712
 
571
- FIELDS:
572
- - ["*"] for all fields
573
- - ["name", "price"] for specific fields
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
- CONDITIONS (Row-level security):
576
- - Uses same filter operators as queries
577
- - {"published": {"eq": true}} - only published records
578
- - {"author_Id": {"eq": "$CURRENT_USER"}} - only own records`, {
579
- role_Id: z.string().describe("Role ID (UUID)"),
580
- collection: z.string().describe("Collection name"),
581
- action: z.enum(["create", "read", "update", "delete"]).describe("Permission action"),
582
- fields: z.array(z.string()).optional().describe("Allowed fields"),
583
- conditions: z.record(z.any()).optional().describe("Row-level security conditions"),
584
- defaultValues: z.record(z.any()).optional().describe("Default values for creation"),
585
- relConditions: z.record(z.any()).optional().describe("Relationship conditions"),
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 result = await permService.createOne(data);
601
- // Reload permissions cache
602
- await permissionService.loadPermissions();
603
- return successResult({ data: result });
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", "Update an existing permission", {
610
- id: z.string().describe("Permission ID"),
611
- role_Id: z.string().optional().describe("Role ID"),
612
- collection: z.string().optional().describe("Collection name"),
613
- action: z.enum(["create", "read", "update", "delete"]).optional().describe("Permission action"),
614
- fields: z.array(z.string()).optional().describe("Allowed fields"),
615
- conditions: z.record(z.any()).optional().describe("Permission conditions"),
616
- defaultValues: z.record(z.any()).optional().describe("Default values"),
617
- relConditions: z.record(z.any()).optional().describe("Relationship conditions"),
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 accountability = getAccountability(extra);
622
- const permService = new ItemsService("baasix_Permission", { accountability });
623
- const result = await permService.updateOne(id, updateData);
624
- // Reload permissions cache
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 ID"),
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 accountability = getAccountability(extra);
638
- const permService = new ItemsService("baasix_Permission", { accountability });
639
- await permService.deleteOne(id);
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", "Reload the permission cache", {}, async (_args, _extra) => {
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 permissionService.loadPermissions();
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, _extra) => {
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 settings = key ? await settingsService.getSetting(key) : await settingsService.getAllSettings();
727
- return successResult({ data: settings });
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 object to update"),
735
- }, async (args, _extra) => {
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 settingsService.updateSettings(settings);
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 with optional filtering", {
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 accountability = getAccountability(extra);
757
- const templateService = new ItemsService("baasix_Template", { accountability });
758
- const query = { page, limit };
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
- query.filter = filter;
761
- const result = await templateService.readByQuery(query, true);
762
- return successResult({
763
- data: result.data,
764
- totalCount: result.totalCount,
765
- page,
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 accountability = getAccountability(extra);
779
- const templateService = new ItemsService("baasix_Template", { accountability });
780
- const result = await templateService.readOne(id);
781
- return successResult({ data: result });
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 accountability = getAccountability(extra);
809
- const templateService = new ItemsService("baasix_Template", { accountability });
810
- const result = await templateService.updateOne(id, updateData);
811
- return successResult({ data: result });
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 by seen status"),
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 accountability = getAccountability(extra);
826
- const notifService = new ItemsService("baasix_Notification", { accountability });
827
- const query = { page, limit };
828
- if (seen !== undefined) {
829
- query.filter = { seen: { eq: seen } };
830
- }
831
- const result = await notifService.readByQuery(query);
832
- return successResult({
833
- data: result.data,
834
- totalCount: result.totalCount,
835
- page,
836
- limit,
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 ID"),
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 accountability = getAccountability(extra);
849
- const notifService = new ItemsService("baasix_Notification", { accountability });
850
- const result = await notifService.updateOne(id, { seen: true });
851
- return successResult({ data: result });
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 Baasix server information and health status", {}, async (_args, _extra) => {
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", "Sort items within a collection (move item before/after another)", {
880
- collection: z.string().describe("Collection name"),
881
- item: z.string().describe("ID of item to move"),
882
- to: z.string().describe("ID of target item to move before"),
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 itemsService = new ItemsService(collection, { accountability });
888
- // Get schema to check if sortEnabled
889
- const schemaDef = schemaManager.getSchemaDefinition(collection);
890
- if (!schemaDef?.sortEnabled) {
891
- throw new Error(`Collection '${collection}' does not have sorting enabled`);
892
- }
893
- // Get both items to swap sort values
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
- message: `Item '${item}' moved before '${to}'`,
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", "Generate reports with grouping and aggregation for a collection", {
910
- collection: z.string().describe("Collection name"),
911
- groupBy: z.string().optional().describe("Field to group by"),
912
- filter: z.record(z.any()).optional().describe("Filter criteria"),
913
- dateRange: z
914
- .object({
915
- start: z.string().optional(),
916
- end: z.string().optional(),
917
- })
918
- .optional()
919
- .describe("Date range filter"),
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, groupBy, filter, dateRange } = args;
1092
+ const { collection, fields, filter, sort, limit, page, aggregate, groupBy } = args;
922
1093
  try {
923
- const accountability = getAccountability(extra);
924
- const itemsService = new ItemsService(collection, { accountability });
925
- const query = { limit: -1 };
926
- // Build filter with date range
927
- const filters = [];
1094
+ const body = {};
1095
+ if (fields)
1096
+ body.fields = fields;
928
1097
  if (filter)
929
- filters.push(filter);
930
- if (dateRange) {
931
- if (dateRange.start) {
932
- filters.push({ createdAt: { gte: dateRange.start } });
933
- }
934
- if (dateRange.end) {
935
- filters.push({ createdAt: { lte: dateRange.end } });
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
- const result = await itemsService.readByQuery(query);
949
- return successResult({
950
- data: result.data,
951
- totalCount: result.totalCount,
952
- groupBy,
953
- dateRange,
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", "Get collection statistics and analytics", {
962
- collections: z.array(z.string()).optional().describe("Specific collections to get stats for"),
963
- timeframe: z.string().optional().describe('Timeframe for stats (e.g., "24h", "7d", "30d")'),
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 { collections, timeframe } = args;
1157
+ const { stats } = args;
966
1158
  try {
967
- const accountability = getAccountability(extra);
968
- // Get all schemas if no specific collections provided
969
- const allSchemas = schemaManager.getSchemas();
970
- const targetCollections = collections || Object.keys(allSchemas).filter((name) => !name.startsWith("baasix_"));
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 a notification to specified users", {
1015
- recipients: z.array(z.string()).describe("Array of user IDs to send notification to"),
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 accountability = getAccountability(extra);
1023
- const itemsService = new ItemsService("baasix_Notification", { accountability });
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
- type: type || "info",
1029
- seen: false,
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: notifications.length,
1034
- notifications,
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 ID", {
1043
- id: z.string().describe("Permission ID"),
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 accountability = getAccountability(extra);
1048
- const itemsService = new ItemsService("baasix_Permission", { accountability });
1049
- const permission = await itemsService.readOne(id);
1050
- return successResult(permission);
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", "Get permissions for a specific role", {
1058
- role: z.string().describe("Role name or ID"),
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
- const accountability = getAccountability(extra);
1063
- // First try to find role by name
1064
- const rolesService = new ItemsService("baasix_Role", { accountability });
1065
- const rolesResult = await rolesService.readByQuery({
1066
- filter: { OR: [{ name: { eq: role } }, { id: { eq: role } }] },
1067
- limit: 1,
1068
- });
1069
- if (!rolesResult.data?.length) {
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 = rolesResult.data[0].id;
1229
+ const roleId = rolesData[0].id;
1073
1230
  // Get permissions for this role
1074
- const permissionsService = new ItemsService("baasix_Permission", { accountability });
1075
- const permissions = await permissionsService.readByQuery({
1076
- filter: { role_Id: { eq: roleId } },
1077
- limit: -1,
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: rolesResult.data[0],
1081
- permissions: permissions.data,
1082
- totalCount: permissions.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", "Update permissions for a role (bulk update)", {
1091
- role: z.string().describe("Role name or ID"),
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 objects to set for the role"),
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 rolesService = new ItemsService("baasix_Role", { accountability });
1106
- const rolesResult = await rolesService.readByQuery({
1107
- filter: { OR: [{ name: { eq: role } }, { id: { eq: role } }] },
1108
- limit: 1,
1109
- });
1110
- if (!rolesResult.data?.length) {
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 = rolesResult.data[0];
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 existing = await permissionsService.readByQuery({
1120
- filter: {
1121
- AND: [{ role_Id: { eq: roleId } }, { collection: { eq: perm.collection } }, { action: { eq: perm.action } }],
1122
- },
1123
- limit: 1,
1124
- });
1125
- if (existing.data?.length) {
1126
- // Update existing
1127
- return permissionsService.updateOne(existing.data[0].id, {
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 permissionsService.createOne({
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 permissionService.loadPermissions();
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", "Get current user information with role and permissions", {
1157
- fields: z.array(z.string()).optional().describe("Specific fields to retrieve"),
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: accountability.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.admin,
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 authentication status and session validity", {}, async (_args, extra) => {
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.admin,
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", "Login with email and password", {
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, _extra) => {
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
- email,
1327
- password,
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 || "Unknown error");
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 axiosError = error;
1349
- return errorResult(axiosError.response?.data?.message || axiosError.message || "Unknown error");
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 (args, _extra) => {
1355
- const { refreshToken } = args;
1572
+ }, async (_args, extra) => {
1356
1573
  try {
1357
- const { default: axios } = await import("axios");
1358
- const baseUrl = `http://localhost:${env.get("PORT") || "8056"}`;
1359
- const response = await axios.post(`${baseUrl}/auth/refresh`, {
1360
- refreshToken,
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 axiosError = error;
1366
- return errorResult(axiosError.response?.data?.message || axiosError.message || "Unknown error");
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", "Update an existing relationship between collections", {
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, _extra) => {
1601
+ }, async (args, extra) => {
1382
1602
  const { sourceCollection, relationshipName, relationshipData } = args;
1383
- try {
1384
- const schema = schemaManager.getSchema(sourceCollection);
1385
- if (!schema) {
1386
- return errorResult(`Collection '${sourceCollection}' not found`);
1387
- }
1388
- const schemaWithRelationships = schema;
1389
- const relationship = schemaWithRelationships.relationships?.find((r) => r.name === relationshipName);
1390
- if (!relationship) {
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
  }