@enfyra/mcp-server 0.0.53 → 0.0.54

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enfyra/mcp-server",
3
- "version": "0.0.53",
3
+ "version": "0.0.54",
4
4
  "description": "MCP server for Enfyra - manage your Enfyra instance via Claude Code",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -16,7 +16,8 @@
16
16
  "scripts": {
17
17
  "start": "node src/index.mjs",
18
18
  "dev": "node --watch src/index.mjs",
19
- "mcp:config": "node src/index.mjs config"
19
+ "mcp:config": "node src/index.mjs config",
20
+ "test": "node --test"
20
21
  },
21
22
  "dependencies": {
22
23
  "@modelcontextprotocol/sdk": "^1.0.0",
@@ -72,10 +72,10 @@ export function buildMcpServerInstructions(apiBaseUrl) {
72
72
  '- REST-first workflow for any feature: **`inspect_feature`** to locate candidates → **`inspect_table`** for table/field/relation/rule context → **`inspect_route`** for handlers/hooks/guards/permissions → **`test_rest_endpoint`** to verify the actual HTTP behavior.',
73
73
  '- Use **`create_column_rule`** for standard request validation, **`create_field_permission`** for per-field read/create/update rules, **`create_route_permission`** for authenticated route access, and **`create_guard`** for pre/post-auth request gates.',
74
74
  '- Prefer these REST inspection/operator tools over raw `query_table` on system tables when changing route behavior. They resolve ids, methods, route paths, code previews, and cache reloads for the model.',
75
- '- If the user asks for a **new route**, **URL path**, **custom API endpoint**, **handler**, **pre-hook**, **post-hook**, or to **test** that kind of logic: use MCP **`create_route`** with **`mainTableId`** of an **existing** table (resolve id via **`get_all_tables`**, **`get_all_metadata`**, or **`get_all_routes`**).',
75
+ '- If the user asks for a **new route**, **URL path**, **custom API endpoint**, **handler**, **pre-hook**, **post-hook**, or to **test** that kind of logic: use MCP **`create_route`** and **omit `mainTableId`**. `mainTable` is only a marker for canonical table routes like `/orders`; custom paths such as `/orders/stats`, `/cloud/admin/hosts`, `/auth/login`, or `/me` must not set it.',
76
76
  '- **Wrong pattern:** calling **`create_table`** just to get an HTTP path, then overriding handlers on the **default** auto route `/{table_name}`. That adds unnecessary schema and breaks the usual CRUD surface for that table.',
77
77
  '- **`create_table`** is only when the user needs **new persisted data** (new entity + columns). It is **not** the right tool when the goal is only a new path or custom script.',
78
- '- **Right pattern:** **`create_route`** → optional **`create_handler`** / **`create_pre_hook`** / **`create_post_hook`** on **that route’s id** (from **`get_all_routes`** after create). Same underlying table can have **multiple** routes (e.g. default CRUD at `/orders` and custom `/orders/stats` both pointing at `mainTable` orders).',
78
+ '- **Right pattern:** **`create_route`** without `mainTableId` → optional **`create_handler`** / **`create_pre_hook`** / **`create_post_hook`** on **that route’s id** (from **`get_all_routes`** after create). Handler/hook code must query explicit repos such as `$ctx.$repos.orders`; do not rely on `$repos.main` for custom routes.',
79
79
  '- **Handler contract:** `create_handler` takes `routeId`, `method` (or `methods` for batch), `sourceCode`, optional `scriptLanguage`, and optional `timeout`. Do **not** send `logic`, `name`, or `compiledCode`; backend CRUD rejects `logic` and `compiledCode` is generated by the server.',
80
80
  '',
81
81
  '### After a new table is created',
@@ -107,8 +107,9 @@ export function buildMcpServerInstructions(apiBaseUrl) {
107
107
  '',
108
108
  '### Relation field format (create_record / update_record)',
109
109
  '- For generic MCP `create_record` and `update_record`, the `data` argument is a **JSON string**, not a JavaScript object. Example: `data: "{\\"name\\":\\"Starter\\"}"`. If the host gives a validation error saying `data` expected string, stringify the object before calling the tool.',
110
- '- Relation fields (mainTable, publishedMethods, availableMethods, handlers, preHooks, postHooks, etc.) use **object references with `id`**:',
111
- ' - **Many-to-one:** `"mainTable": {"id": 4}` (single object with id)',
110
+ '- Relation fields (publishedMethods, availableMethods, handlers, preHooks, postHooks, etc.) use **object references with `id`**:',
111
+ '- **mainTable warning:** do not set `mainTable` on custom routes. It is reserved for canonical table routes only.',
112
+ ' - **Many-to-one:** `"someRelation": {"id": 4}` (single object with id)',
112
113
  ' - **One-to-many / many-to-many:** `"publishedMethods": [{"id": 1}, {"id": 2}]` (array of objects with id)',
113
114
  '- **Method IDs** (for REST route publishedMethods, availableMethods, skipRoleGuardMethods): GET=1, POST=2, PATCH=3, DELETE=4. Query `method_definition` table if unsure.',
114
115
  '- **Wrong:** `"publishedMethods": ["GET"]` or `"publishedMethods": [{"method": "GET"}]` — rejected or silently ignored.',
@@ -198,10 +199,10 @@ export function buildMcpServerInstructions(apiBaseUrl) {
198
199
  '- **Not all system tables have a REST route.** `query_table`, `find_one_record`, `create_record`, etc. all go through the dynamic REST API and will return **404** if the table has no registered route.',
199
200
  '- **`column_definition` and `session_definition` have NO route** — do NOT call `query_table("column_definition", …)` or `query_table("session_definition", …)`. They will 404.',
200
201
  '- Do not invent singular/legacy system route names such as `hook_definition`, `oauth_provider_definition`, or physical FK tables. If a route name is not listed by `get_all_routes`, it is not a REST endpoint for generic CRUD. Use the concrete tables (`pre_hook_definition`, `post_hook_definition`, `oauth_config_definition`, etc.) or the dedicated MCP tool.',
201
- '- To check which tables are accessible via MCP tools, call `get_all_routes` and look for the route whose `mainTable.id` matches the table you need, or `get_all_metadata` to see all table names.',
202
+ '- To check which tables have canonical CRUD routes, call `get_all_routes` and look for `mainTable`. Custom routes intentionally have no `mainTable`; inspect their handlers/hooks to see which repos they touch.',
202
203
  '- **Tables confirmed to have REST routes (system):** `bootstrap_script_definition`, `column_rule_definition`, `cors_origin_definition`, `extension_definition`, `field_permission_definition`, `file_definition`, `file_permission_definition`, `flow_definition`, `flow_execution_definition`, `flow_step_definition`, `folder_definition`, `gql_definition`, `guard_definition`, `guard_rule_definition`, `menu_definition`, `method_definition`, `oauth_account_definition`, `oauth_config_definition`, `package_definition`, `post_hook_definition`, `pre_hook_definition`, `relation_definition`, `role_definition`, `route_definition`, `route_handler_definition`, `route_permission_definition`, `schema_migration_definition`, `setting_definition`, `storage_config_definition`, `table_definition`, `user_definition`, `websocket_definition`, `websocket_event_definition`.',
203
- '- **Tables without REST routes (internal/system only):** `column_definition`, `session_definition`. Columns are managed indirectly via cascade on `table_definition` (POST/PATCH with columns arrays). The `create_table`, `create_column`/`add_column`, `update_column`, and `delete_column`/`remove_column` MCP tools handle this automatically.',
204
- '- Use `create_column`/`add_column` for new scalar fields. These tools accept column metadata such as `isNullable`, `isUnique`, `isPublished`, `isPrimary`, `isGenerated`, `isSystem`, `defaultValue`, `description`, and `options`; set `isPublished=false` directly when creating secret/internal fields such as `*_encrypted`. When patching an existing table, only persisted columns with an `id`/`_id` belong in the cascade payload; metadata projections such as `createdAt`, `updatedAt`, or relation-derived FK display fields without an id are not valid column-definition patch rows.',
204
+ '- **Tables without REST routes (internal/system only):** `column_definition`, `session_definition`. Columns are managed indirectly via cascade on `table_definition` (POST/PATCH with columns arrays). The `create_table`, `create_column`/`add_column`, `update_column`, and `delete_column`/`remove_column` MCP tools handle this automatically by reading full table metadata first.',
205
+ '- Use `create_column`/`add_column` for new scalar fields. These tools accept column metadata such as `isNullable`, `isUnique`, `isPublished`, `isPrimary`, `isGenerated`, `isSystem`, `defaultValue`, `description`, and `options`; set `isPublished=false` directly when creating secret/internal fields such as `*_encrypted`. When patching an existing table, only persisted columns with an `id`/`_id` belong in the cascade payload; metadata projections such as `createdAt`, `updatedAt`, or relation-derived FK display fields without an id are not valid column-definition patch rows. Never rebuild a schema cascade from `table_definition?fields=columns.*`, because nested relation fields may be paginated/truncated.',
205
206
  '- Prefer `create_relation`/`add_relation` and `delete_relation`/`remove_relation` for relation schema changes because they preserve the full table relation list and handle schema-confirm retry. Direct `create_record` on `relation_definition` only edits metadata and is not the canonical schema migration path.',
206
207
  '',
207
208
  '### Body validation & column rules',
@@ -220,7 +221,7 @@ export function buildMcpServerInstructions(apiBaseUrl) {
220
221
  '',
221
222
  '### Resolving the real REST path',
222
223
  '- Do **not** assume `route_definition.path` always equals `table_definition.name`. Paths are data-driven (custom prefixes, renames, multiple routes per table).',
223
- '- When unsure of the URL path, use MCP **`get_all_routes`** (or **`get_all_metadata`**) to read each route’s **path** and **mainTable** before stating a full URL.',
224
+ '- When unsure of the URL path, use MCP **`get_all_routes`** (or **`get_all_metadata`**) to read each route’s **path**. Treat `mainTable` as canonical CRUD-route metadata only, not as the owner table for custom routes.',
224
225
  '',
225
226
  '### MongoDB vs SQL primary key',
226
227
  '- On **SQL**, filters often use **`id`**. On **MongoDB**, documents may use **`_id`** — a filter for one row might be `{"_id":{"_eq":"..."}}` instead of `id`, depending on metadata.',
@@ -4,13 +4,42 @@
4
4
  import { z } from 'zod';
5
5
  import { fetchAPI } from './fetch.js';
6
6
 
7
+ export function normalizeTablesFromMetadata(metadata) {
8
+ const tablesSource = metadata?.data?.tables || metadata?.tables || metadata?.data || [];
9
+ return Array.isArray(tablesSource)
10
+ ? tablesSource
11
+ : Object.values(tablesSource || {});
12
+ }
13
+
14
+ export function resolveTableFromMetadata(metadata, tableId) {
15
+ return normalizeTablesFromMetadata(metadata)
16
+ .find((table) => String(getId(table)) === String(tableId)) || null;
17
+ }
18
+
7
19
  /**
8
- * Helper: fetch table with columns and relations
20
+ * Helper: fetch table with full columns and relations.
21
+ * Dynamic table_definition relation fields can be paginated/truncated, so schema
22
+ * cascade tools must use /metadata as the complete source of columns/relations.
9
23
  */
10
- async function fetchTableWithDetails(ENFYRA_API_URL, tableId) {
24
+ export async function fetchTableWithDetails(ENFYRA_API_URL, tableId) {
11
25
  const filter = encodeURIComponent(JSON.stringify({ id: { _eq: tableId } }));
12
- const result = await fetchAPI(ENFYRA_API_URL, `/table_definition?filter=${filter}&limit=1&fields=*,columns.*,relations.*`);
13
- return result?.data?.[0] || result?.[0] || null;
26
+ const [tableResult, metadata] = await Promise.all([
27
+ fetchAPI(ENFYRA_API_URL, `/table_definition?filter=${filter}&limit=1&fields=*`),
28
+ fetchAPI(ENFYRA_API_URL, '/metadata'),
29
+ ]);
30
+ const tableData = tableResult?.data?.[0] || tableResult?.[0] || null;
31
+ const metadataTable = resolveTableFromMetadata(metadata, tableId);
32
+ if (!metadataTable) {
33
+ throw new Error(`Full metadata for table ${tableId} was not found; refusing schema cascade patch.`);
34
+ }
35
+ if (!Array.isArray(metadataTable.columns)) {
36
+ throw new Error(`Full metadata for table ${tableId} did not include columns; refusing schema cascade patch.`);
37
+ }
38
+ return {
39
+ ...(tableData || metadataTable),
40
+ columns: metadataTable.columns,
41
+ relations: Array.isArray(metadataTable.relations) ? metadataTable.relations : [],
42
+ };
14
43
  }
15
44
 
16
45
  /**
@@ -99,6 +128,41 @@ function getPatchableColumns(columns) {
99
128
  .map(normalizeColumnForTablePatch);
100
129
  }
101
130
 
131
+ function getMissingIds(beforeIds, afterIds, excludedIds = []) {
132
+ const afterSet = new Set(afterIds.map(String));
133
+ const excludedSet = new Set(excludedIds.map(String));
134
+ return beforeIds
135
+ .map(String)
136
+ .filter((id) => !excludedSet.has(id) && !afterSet.has(id));
137
+ }
138
+
139
+ async function verifyColumnCascade(ENFYRA_API_URL, tableId, beforeIds, {
140
+ action,
141
+ columnId,
142
+ columnName,
143
+ }) {
144
+ const tableData = await fetchTableWithDetails(ENFYRA_API_URL, tableId);
145
+ const afterColumns = getPatchableColumns(tableData.columns);
146
+ const afterIds = afterColumns.map((column) => String(getId(column)));
147
+ const excludedIds = action === 'delete' ? [columnId] : [];
148
+ const missingIds = getMissingIds(beforeIds, afterIds, excludedIds);
149
+ if (missingIds.length > 0) {
150
+ throw new Error(`Schema cascade verification failed: unrelated column ids disappeared: ${missingIds.join(', ')}`);
151
+ }
152
+
153
+ if (action === 'create' && !afterColumns.some((column) => column.name === columnName)) {
154
+ throw new Error(`Schema cascade verification failed: column "${columnName}" was not found after create.`);
155
+ }
156
+ if (action === 'delete' && afterIds.includes(String(columnId))) {
157
+ throw new Error(`Schema cascade verification failed: column ${columnId} still exists after delete.`);
158
+ }
159
+ if (action === 'update' && !afterIds.includes(String(columnId))) {
160
+ throw new Error(`Schema cascade verification failed: column ${columnId} was not found after update.`);
161
+ }
162
+
163
+ return afterColumns;
164
+ }
165
+
102
166
  function buildColumnDefinition({
103
167
  name,
104
168
  type,
@@ -138,8 +202,13 @@ export function registerTableTools(server, ENFYRA_API_URL) {
138
202
  }
139
203
 
140
204
  const existingColumns = getPatchableColumns(tableData.columns);
205
+ const beforeIds = existingColumns.map((column) => String(getId(column)));
141
206
  const newCol = buildColumnDefinition(args);
142
207
  const result = await patchTableAutoConfirm(ENFYRA_API_URL, args.tableId, { columns: [...existingColumns, newCol] });
208
+ await verifyColumnCascade(ENFYRA_API_URL, args.tableId, beforeIds, {
209
+ action: 'create',
210
+ columnName: args.name,
211
+ });
143
212
 
144
213
  return {
145
214
  content: [{ type: 'text', text: `Column "${args.name}" added to table ${args.tableId}.\n\n${JSON.stringify(result, null, 2)}` }],
@@ -170,12 +239,20 @@ export function registerTableTools(server, ENFYRA_API_URL) {
170
239
  return { content: [{ type: 'text', text: `Error: Table with ID ${tableId} not found.` }] };
171
240
  }
172
241
 
173
- const columns = (tableData.columns || [])
174
- .filter((col) => getId(col) !== null)
242
+ const existingColumns = getPatchableColumns(tableData.columns);
243
+ const beforeIds = existingColumns.map((column) => String(getId(column)));
244
+ if (!beforeIds.includes(String(columnId))) {
245
+ throw new Error(`Column ${columnId} was not found on table ${tableId}; refusing schema cascade patch.`);
246
+ }
247
+
248
+ const columns = existingColumns
175
249
  .filter(col => String(getId(col)) !== String(columnId))
176
- .map(normalizeColumnForTablePatch);
177
250
 
178
251
  const result = await patchTableAutoConfirm(ENFYRA_API_URL, tableId, { columns });
252
+ await verifyColumnCascade(ENFYRA_API_URL, tableId, beforeIds, {
253
+ action: 'delete',
254
+ columnId,
255
+ });
179
256
 
180
257
  return {
181
258
  content: [{ type: 'text', text: `Column ${columnId} deleted from table ${tableId}.\n\n${JSON.stringify(result, null, 2)}` }],
@@ -256,7 +333,7 @@ export function registerTableTools(server, ENFYRA_API_URL) {
256
333
  'create_table',
257
334
  [
258
335
  'Create a new table definition with an auto-included `id` primary key column.',
259
- '**Not** for adding a custom API path or handler only — for that use **`create_route`** with an existing `mainTableId`. Use **`create_table`** when the user needs new stored data (new entity).',
336
+ '**Not** for adding a custom API path or handler only — for that use **`create_route`** without `mainTableId`. Use **`create_table`** when the user needs new stored data (new entity).',
260
337
  'PREFERRED: pass `columns` and `relations` params as JSON arrays to create a table WITH columns and relations in one call (cascade). Only use create_column/create_relation separately when adding to an existing table later.',
261
338
  'Indexes and uniques are first-class table metadata. Use `indexes` for query performance and `uniques` for data integrity. Each entry is a logical field group such as [["member","isRead","conversation"]] or [{"value":["message","member"]}]. Relation property names are allowed; Enfyra resolves them to physical FK columns.',
262
339
  'Relations are supported in this same create_table call when the target table already exists. Each relation uses { targetTable, type, propertyName, inversePropertyName?, mappedBy?, isNullable?, onDelete? }; targetTable may be a table id or {id}.',
@@ -383,7 +460,7 @@ export function registerTableTools(server, ENFYRA_API_URL) {
383
460
  [
384
461
  'Add a column to an existing table via PATCH /table_definition/{tableId}.',
385
462
  'Columns are managed through cascade with table_definition — there is NO direct /column_definition endpoint.',
386
- 'This tool fetches existing columns, keeps only persisted column rows with id/_id, appends the new one, and PATCHes the table.',
463
+ 'This tool reads full table metadata, keeps only persisted column rows with id/_id, appends the new one, PATCHes the table, and verifies unrelated columns survived.',
387
464
  'Generated metadata projections such as createdAt, updatedAt, or relation-derived FK display fields without id are not valid cascade rows and are skipped.',
388
465
  'Run schema changes sequentially — migration locks DB per operation.',
389
466
  ].join(' '),
@@ -398,7 +475,7 @@ export function registerTableTools(server, ENFYRA_API_URL) {
398
475
  [
399
476
  'Alias for create_column. Add a column to an existing table through the canonical table_definition cascade.',
400
477
  'Use this for schema additions, including hidden secret fields with isPublished=false.',
401
- 'Skips non-persisted generated/derived column metadata without id/_id when rebuilding the table columns payload.',
478
+ 'Reads full table metadata and skips non-persisted generated/derived column metadata without id/_id when rebuilding the table columns payload.',
402
479
  'Run schema changes sequentially — migration locks DB per operation.',
403
480
  ].join(' '),
404
481
  columnCreateSchema,
@@ -411,7 +488,7 @@ export function registerTableTools(server, ENFYRA_API_URL) {
411
488
  'update_column',
412
489
  [
413
490
  'Update an existing column on a table via PATCH /table_definition/{tableId}.',
414
- 'Fetches table columns, keeps only persisted rows with id/_id, modifies the target column, and PATCHes the table.',
491
+ 'Reads full table metadata, keeps only persisted rows with id/_id, modifies the target column, PATCHes the table, and verifies unrelated columns survived.',
415
492
  'Generated metadata projections such as createdAt, updatedAt, or relation-derived FK display fields without id are skipped.',
416
493
  'Run schema changes sequentially — migration locks DB per operation.',
417
494
  ].join(' '),
@@ -432,7 +509,13 @@ export function registerTableTools(server, ENFYRA_API_URL) {
432
509
  return { content: [{ type: 'text', text: `Error: Table with ID ${tableId} not found.` }] };
433
510
  }
434
511
 
435
- const columns = (tableData.columns || []).filter((col) => getId(col) !== null).map(col => {
512
+ const existingColumns = getPatchableColumns(tableData.columns);
513
+ const beforeIds = existingColumns.map((column) => String(getId(column)));
514
+ if (!beforeIds.includes(String(columnId))) {
515
+ throw new Error(`Column ${columnId} was not found on table ${tableId}; refusing schema cascade patch.`);
516
+ }
517
+
518
+ const columns = existingColumns.map(col => {
436
519
  const rest = normalizeColumnForTablePatch(col);
437
520
  if (String(getId(col)) === String(columnId)) {
438
521
  if (name !== undefined) rest.name = name;
@@ -447,6 +530,10 @@ export function registerTableTools(server, ENFYRA_API_URL) {
447
530
  });
448
531
 
449
532
  const result = await patchTableAutoConfirm(ENFYRA_API_URL, tableId, { columns });
533
+ await verifyColumnCascade(ENFYRA_API_URL, tableId, beforeIds, {
534
+ action: 'update',
535
+ columnId,
536
+ });
450
537
 
451
538
  return {
452
539
  content: [{ type: 'text', text: `Column ${columnId} updated on table ${tableId}.\n\n${JSON.stringify(result, null, 2)}` }],
@@ -460,7 +547,7 @@ export function registerTableTools(server, ENFYRA_API_URL) {
460
547
  'delete_column',
461
548
  [
462
549
  'Delete a column from a table via PATCH /table_definition/{tableId}.',
463
- 'Fetches table columns, keeps only persisted rows with id/_id, removes the target, and PATCHes the table.',
550
+ 'Reads full table metadata, keeps only persisted rows with id/_id, removes the target, PATCHes the table, and verifies unrelated columns survived.',
464
551
  'The physical column is dropped from the database. System columns (id, createdAt, updatedAt) cannot be deleted.',
465
552
  'Run schema changes sequentially — migration locks DB per operation.',
466
553
  ].join(' '),
@@ -475,7 +562,7 @@ export function registerTableTools(server, ENFYRA_API_URL) {
475
562
  [
476
563
  'Alias for delete_column. Remove a column through the canonical table_definition cascade.',
477
564
  'This drops the physical column. Confirm destructive schema changes before calling.',
478
- 'Skips non-persisted generated/derived column metadata without id/_id when rebuilding the table columns payload.',
565
+ 'Reads full table metadata and skips non-persisted generated/derived column metadata without id/_id when rebuilding the table columns payload.',
479
566
  'Run schema changes sequentially — migration locks DB per operation.',
480
567
  ].join(' '),
481
568
  columnDeleteSchema,
@@ -32,7 +32,7 @@ const CAPABILITY_AREAS = [
32
32
  {
33
33
  area: 'Dynamic REST API',
34
34
  tables: ['route_definition', 'route_handler_definition', 'pre_hook_definition', 'post_hook_definition', 'route_permission_definition', 'method_definition'],
35
- workflow: 'Create paths with create_route on an existing main table, then add handlers/hooks. REST methods are GET/POST/PATCH/DELETE.',
35
+ workflow: 'Create custom paths with create_route without mainTableId, then add handlers/hooks. mainTableId is only for canonical table routes like /table_name. REST methods are GET/POST/PATCH/DELETE.',
36
36
  },
37
37
  {
38
38
  area: 'Auth, roles, sessions, OAuth',
@@ -1387,15 +1387,16 @@ server.tool('get_all_routes', 'List route definitions with minimal fields. Call
1387
1387
  server.tool(
1388
1388
  'create_route',
1389
1389
  [
1390
- '**Use this when the user wants a new REST API route or path** — not `create_table`. A route links a URL path to an existing table (`mainTableId`) and sets HTTP methods.',
1391
- 'Do NOT create a new table_definition only to expose an endpoint; pick `mainTableId` from existing metadata unless the user explicitly needs new tables/columns.',
1390
+ '**Use this when the user wants a new REST API route or path** — not `create_table`. Custom routes must omit `mainTableId`.',
1391
+ '`mainTableId` is only a marker for canonical table routes such as `/orders`; do not set it for `/orders/stats`, `/cloud/admin/hosts`, `/auth/login`, or any custom path.',
1392
+ 'Do NOT create a new table_definition only to expose an endpoint; create a route without `mainTableId`, then have the handler/hook query explicit repos such as `$ctx.$repos.orders`.',
1392
1393
  'availableMethods = which REST verbs the route responds to. publishedMethods = which REST verbs are public (no auth). GraphQL is enabled separately through gql_definition/update_table graphqlEnabled.',
1393
1394
  'After creation the tool auto-reloads routes. Then create handlers for specific methods via create_handler on this route id.',
1394
- 'Flow: resolve table id → create_route → create_handler (per method) → optionally create_pre_hook / create_post_hook → test via HTTP or admin test APIs (see server instructions).',
1395
+ 'Flow: create_route → create_handler (per method) → optionally create_pre_hook / create_post_hook → test via HTTP or admin test APIs (see server instructions).',
1395
1396
  ].join(' '),
1396
1397
  {
1397
1398
  path: z.string().describe('URL path, must start with / (e.g., "/my-endpoint")'),
1398
- mainTableId: z.union([z.string(), z.number()]).describe('ID of the table_definition this route operates on. The route\'s $repos.main will query this table.'),
1399
+ mainTableId: z.union([z.string(), z.number()]).optional().describe('Only set for the canonical table route `/<table_name>`. Omit for every custom route.'),
1399
1400
  methods: z.array(z.enum(['GET', 'POST', 'PATCH', 'DELETE']))
1400
1401
  .describe('HTTP methods this route supports (availableMethods). Common: ["GET","POST","PATCH","DELETE"]'),
1401
1402
  publishedMethods: z.array(z.enum(['GET', 'POST', 'PATCH', 'DELETE'])).optional()
@@ -1408,12 +1409,15 @@ server.tool(
1408
1409
 
1409
1410
  const body = {
1410
1411
  path: routePath.startsWith('/') ? routePath : '/' + routePath,
1411
- mainTable: { id: mainTableId },
1412
1412
  isEnabled,
1413
1413
  description,
1414
1414
  availableMethods: resolveMethodIds(methodMap, methods),
1415
1415
  };
1416
1416
 
1417
+ if (mainTableId !== undefined && mainTableId !== null) {
1418
+ body.mainTable = { id: mainTableId };
1419
+ }
1420
+
1417
1421
  if (publishedMethods && publishedMethods.length > 0) {
1418
1422
  body.publishedMethods = resolveMethodIds(methodMap, publishedMethods);
1419
1423
  }
@@ -1431,7 +1435,7 @@ server.tool(
1431
1435
  route: {
1432
1436
  id: getId(created),
1433
1437
  path: created?.path,
1434
- mainTableId,
1438
+ mainTableId: mainTableId ?? null,
1435
1439
  availableMethods: methods,
1436
1440
  publishedMethods: publishedMethods || [],
1437
1441
  },