@enfyra/mcp-server 0.1.52 → 0.1.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/README.md CHANGED
@@ -246,6 +246,18 @@ Without a target flag, interactive mode asks which client to configure. Non-inte
246
246
 
247
247
  For normal apps and demos, enter the app/admin URL such as `http://localhost:3000` or `https://demo.enfyra.io`. Treat the direct Enfyra backend host as private infrastructure unless you are debugging Enfyra core/server internals.
248
248
 
249
+ ## Metadata Contract
250
+
251
+ The MCP server uses Enfyra metadata lazily:
252
+
253
+ - `GET /metadata` returns only `dbType` and `enfyraVersion`.
254
+ - `GET /metadata/{tableName}` returns one permission-projected table schema.
255
+ - `get_all_tables` and normal discovery use a lightweight `enfyra_table` catalog.
256
+ - `get_table_metadata`, `inspect_table`, mutation validation, and schema operations fetch only the table schemas they need.
257
+ - Explicit broad schema searches fan out to per-table metadata with bounded concurrency and reuse the MCP runtime cache.
258
+
259
+ Use `get_all_tables` for table names, then `get_table_metadata` or `inspect_table` for columns and relations. `get_all_metadata(includeFull=true)` is an explicit broad operation and should not be used as startup discovery.
260
+
249
261
  ## Common Examples
250
262
 
251
263
  Use `get_enfyra_examples` from the MCP tool list when asking an LLM to generate implementation patterns. It returns focused examples for:
@@ -21,6 +21,7 @@ export function buildMcpServerInstructions(apiBaseUrl, options = {}) {
21
21
  '- Primary success criterion: one-shot done. A capable baseline model (GLM-5-Turbo or stronger) should finish a fully specified, non-destructive task in one task turn, using the smallest necessary sequence of MCP calls and without asking the user to relay intermediate plans or retry avoidable failures.',
22
22
  '- Primary efficiency criterion: lazy-load by domain. Startup instructions are only a router; load required knowledge, examples, live metadata, script contexts, and theme contracts only when that exact task needs them. Never load broad discovery, full knowledge, or a full reference merely as a precaution.',
23
23
  '- For target sanity checks, call `get_enfyra_api_context`; do not load broad metadata only to confirm the API base.',
24
+ '- Metadata is lazy: `GET /metadata` is runtime context only, while table schema comes from `GET /metadata/:name`. Use `get_all_tables` for a lightweight catalog and `get_table_metadata` or `inspect_table` for one schema; do not preload all table schemas.',
24
25
  '- When the goal or tool path is ambiguous, call `discover_enfyra_workflows` with intent/risk/surface; follow its `primaryPath` and `avoidTools`. Skip it when the exact focused operation is already clear.',
25
26
  '- Inspect only the target: use `inspect_table`, `inspect_route`, `inspect_feature`, `search_admin_extensions`, or `search_runtime_zone`. Do not run broad discovery after the target is known.',
26
27
  '- Load examples with `get_enfyra_examples` only for an unfamiliar pattern. For extension UI, load `get_extension_theme_contract`; call build_extension_ui only when the requested UI needs those contracts.',
@@ -1 +1 @@
1
- {"version":3,"file":"mcp-instructions.js","sourceRoot":"","sources":["../../src/lib/mcp-instructions.ts"],"names":[],"mappings":"AAAA,mEAAmE;AACnE,MAAM,UAAU,gBAAgB,CAAC,UAAU;IACzC,MAAM,IAAI,GAAG,MAAM,CAAC,UAAU,IAAI,EAAE,CAAC,CAAC,OAAO,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC;IACzD,OAAO;QACL,cAAc,EAAE,GAAG,IAAI,UAAU;QACjC,gBAAgB,EAAE,GAAG,IAAI,iBAAiB;KAC3C,CAAC;AACJ,CAAC;AAMD,MAAM,UAAU,0BAA0B,CAAC,UAAU,EAAE,UAAiC,EAAE;IACxF,MAAM,IAAI,GAAG,MAAM,CAAC,UAAU,IAAI,EAAE,CAAC,CAAC,OAAO,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC;IACzD,MAAM,EAAE,cAAc,EAAE,gBAAgB,EAAE,GAAG,gBAAgB,CAAC,UAAU,CAAC,CAAC;IAC1E,MAAM,cAAc,GAAG,OAAO,EAAE,cAAc,IAAI,IAAI,CAAC;IAEvD,OAAO;QACL,eAAe;QACf,EAAE;QACF,gCAAgC,IAAI,KAAK;QACzC,wBAAwB,cAAc,YAAY,gBAAgB,KAAK;QACvE,GAAG,CAAC,cAAc,CAAC,CAAC,CAAC,CAAC,EAAE,EAAE,cAAc,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;QAC/C,EAAE;QACF,qBAAqB;QACrB,gTAAgT;QAChT,mTAAmT;QACnT,qHAAqH;QACrH,6MAA6M;QAC7M,gMAAgM;QAChM,6MAA6M;QAC7M,4EAA4E;QAC5E,2NAA2N;QAC3N,kTAAkT;QAClT,6LAA6L;QAC7L,yLAAyL;QACzL,mPAAmP;KACpP,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AACf,CAAC"}
1
+ {"version":3,"file":"mcp-instructions.js","sourceRoot":"","sources":["../../src/lib/mcp-instructions.ts"],"names":[],"mappings":"AAAA,mEAAmE;AACnE,MAAM,UAAU,gBAAgB,CAAC,UAAU;IACzC,MAAM,IAAI,GAAG,MAAM,CAAC,UAAU,IAAI,EAAE,CAAC,CAAC,OAAO,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC;IACzD,OAAO;QACL,cAAc,EAAE,GAAG,IAAI,UAAU;QACjC,gBAAgB,EAAE,GAAG,IAAI,iBAAiB;KAC3C,CAAC;AACJ,CAAC;AAMD,MAAM,UAAU,0BAA0B,CAAC,UAAU,EAAE,UAAiC,EAAE;IACxF,MAAM,IAAI,GAAG,MAAM,CAAC,UAAU,IAAI,EAAE,CAAC,CAAC,OAAO,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC;IACzD,MAAM,EAAE,cAAc,EAAE,gBAAgB,EAAE,GAAG,gBAAgB,CAAC,UAAU,CAAC,CAAC;IAC1E,MAAM,cAAc,GAAG,OAAO,EAAE,cAAc,IAAI,IAAI,CAAC;IAEvD,OAAO;QACL,eAAe;QACf,EAAE;QACF,gCAAgC,IAAI,KAAK;QACzC,wBAAwB,cAAc,YAAY,gBAAgB,KAAK;QACvE,GAAG,CAAC,cAAc,CAAC,CAAC,CAAC,CAAC,EAAE,EAAE,cAAc,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;QAC/C,EAAE;QACF,qBAAqB;QACrB,gTAAgT;QAChT,mTAAmT;QACnT,qHAAqH;QACrH,gQAAgQ;QAChQ,6MAA6M;QAC7M,gMAAgM;QAChM,6MAA6M;QAC7M,4EAA4E;QAC5E,2NAA2N;QAC3N,kTAAkT;QAClT,6LAA6L;QAC7L,yLAAyL;QACzL,mPAAmP;KACpP,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AACf,CAAC"}
@@ -0,0 +1,8 @@
1
+ import type { MetadataContext, MetadataTableCatalogEntry, UnknownRecord } from "./types.js";
2
+ export declare function unwrapTableMetadata(result: unknown): UnknownRecord | null;
3
+ export declare function fetchMetadataContext(apiUrl: string): Promise<MetadataContext>;
4
+ export declare function fetchTableMetadata(apiUrl: string, tableName: string): Promise<UnknownRecord>;
5
+ export declare function fetchTableCatalog(apiUrl: string): Promise<MetadataTableCatalogEntry[]>;
6
+ export declare function fetchTableMetadataByRef(apiUrl: string, tableRef: unknown): Promise<UnknownRecord>;
7
+ export declare function resolveTableCatalogEntry(tables: MetadataTableCatalogEntry[], tableRef: unknown): MetadataTableCatalogEntry | null;
8
+ export declare function fetchMetadataTables(apiUrl: string, catalog: MetadataTableCatalogEntry[], concurrency?: number): Promise<UnknownRecord[]>;
@@ -0,0 +1,69 @@
1
+ import { fetchAPI } from "./fetch.js";
2
+ const TABLE_CATALOG_FIELDS = "id,name,alias,description,isSingleRecord";
3
+ const DEFAULT_METADATA_CONCURRENCY = 4;
4
+ function unwrapData(result) {
5
+ if (Array.isArray(result))
6
+ return result;
7
+ if (result && typeof result === "object" && Array.isArray(result.data)) {
8
+ return result.data;
9
+ }
10
+ return [];
11
+ }
12
+ export function unwrapTableMetadata(result) {
13
+ if (!result || typeof result !== "object")
14
+ return null;
15
+ const record = result;
16
+ const data = record.data;
17
+ if (data && typeof data === "object" && !Array.isArray(data))
18
+ return data;
19
+ const table = record.table;
20
+ if (table && typeof table === "object" && !Array.isArray(table))
21
+ return table;
22
+ return null;
23
+ }
24
+ export async function fetchMetadataContext(apiUrl) {
25
+ const result = await fetchAPI(apiUrl, "/metadata");
26
+ return {
27
+ dbType: result?.dbType ?? null,
28
+ enfyraVersion: result?.enfyraVersion ?? null,
29
+ };
30
+ }
31
+ export async function fetchTableMetadata(apiUrl, tableName) {
32
+ const result = await fetchAPI(apiUrl, `/metadata/${encodeURIComponent(tableName)}`);
33
+ const table = unwrapTableMetadata(result);
34
+ if (!table)
35
+ throw new Error(`Metadata for table "${tableName}" did not contain a table object.`);
36
+ return table;
37
+ }
38
+ export async function fetchTableCatalog(apiUrl) {
39
+ const result = await fetchAPI(apiUrl, `/enfyra_table?fields=${encodeURIComponent(TABLE_CATALOG_FIELDS)}&limit=0&sort=name`);
40
+ return unwrapData(result)
41
+ .filter((item) => Boolean(item && typeof item === "object" && typeof item.name === "string"));
42
+ }
43
+ export async function fetchTableMetadataByRef(apiUrl, tableRef) {
44
+ const catalog = await fetchTableCatalog(apiUrl);
45
+ const entry = resolveTableCatalogEntry(catalog, tableRef);
46
+ if (!entry)
47
+ throw new Error(`Table not found: ${String(tableRef)}`);
48
+ return fetchTableMetadata(apiUrl, entry.name);
49
+ }
50
+ export function resolveTableCatalogEntry(tables, tableRef) {
51
+ const normalizedRef = String(tableRef ?? "");
52
+ return tables.find((table) => (String(table.id ?? table._id ?? "") === normalizedRef
53
+ || table.name === normalizedRef
54
+ || table.alias === normalizedRef)) ?? null;
55
+ }
56
+ export async function fetchMetadataTables(apiUrl, catalog, concurrency = DEFAULT_METADATA_CONCURRENCY) {
57
+ const tables = new Array(catalog.length);
58
+ let nextIndex = 0;
59
+ const workerCount = Math.min(Math.max(1, concurrency), catalog.length);
60
+ await Promise.all(Array.from({ length: workerCount }, async () => {
61
+ while (nextIndex < catalog.length) {
62
+ const index = nextIndex;
63
+ nextIndex += 1;
64
+ tables[index] = await fetchTableMetadata(apiUrl, catalog[index].name);
65
+ }
66
+ }));
67
+ return tables;
68
+ }
69
+ //# sourceMappingURL=metadata-client.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"metadata-client.js","sourceRoot":"","sources":["../../src/lib/metadata-client.ts"],"names":[],"mappings":"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"}
@@ -78,6 +78,7 @@ export declare function buildExtensionUploadModalSnippet(input: any): {
78
78
  };
79
79
  export declare function buildExtensionApiUsageSnippet(input?: AnyRecord): {
80
80
  action: string;
81
+ operation: string;
81
82
  snippet: string;
82
83
  contract: string[];
83
84
  };
@@ -1,6 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { createHash } from 'node:crypto';
3
3
  import { fetchAPI } from './fetch.js';
4
+ import { fetchTableCatalog, fetchTableMetadataByRef, resolveTableCatalogEntry } from './metadata-client.js';
4
5
  import { validatePortableScriptSource, validateScriptSourceIfPresent } from './mutation-guards.js';
5
6
  import { assertDynamicCodeKnowledgeAck, assertDynamicCodeKnowledgeAckIf, assertExtensionKnowledgeAck, assertGlobalRulesAck, dynamicCodeKnowledgeAckParam, extensionKnowledgeAckParam, globalRulesAckParam, } from './required-knowledge.js';
6
7
  const AUTO_INJECTED_EXTENSION_COMPONENT_TAGS = [
@@ -849,12 +850,34 @@ function toPascalIdentifier(value, fallback = 'Items') {
849
850
  export function buildExtensionApiUsageSnippet(input = {}) {
850
851
  const resource = String(input.resource || input.name || 'items');
851
852
  const pascal = toPascalIdentifier(resource, 'Items');
852
- const path = input.path || `/${resource}`;
853
- const method = String(input.method || 'GET').toUpperCase();
853
+ const operation = String(input.operation || input.mode || input.intent || '').toLowerCase() || String(input.method || 'GET').toLowerCase();
854
+ const normalizedOperation = {
855
+ get: 'list',
856
+ read: 'list',
857
+ load: 'list',
858
+ post: 'create',
859
+ patch: 'update',
860
+ put: 'update',
861
+ del: 'delete',
862
+ remove: 'delete',
863
+ destroy: 'delete',
864
+ }[operation] || operation;
865
+ const defaultMethodByOperation = {
866
+ list: 'GET',
867
+ find_one: 'GET',
868
+ create: 'POST',
869
+ update: 'PATCH',
870
+ delete: 'DELETE',
871
+ batch_update: 'PATCH',
872
+ batch_delete: 'DELETE',
873
+ };
874
+ const method = String(input.method || defaultMethodByOperation[normalizedOperation] || 'GET').toUpperCase();
875
+ const rawPath = String(input.path || `/${resource}`);
876
+ const path = rawPath.replace(/\/:id\/?$/, '');
854
877
  const responseName = input.responseName || `${resource}Response`;
855
878
  const pendingName = input.pendingName || `${resource}Pending`;
856
879
  const errorName = input.errorName || `${resource}Error`;
857
- const executeName = input.executeName || (method === 'GET' ? `load${pascal}` : `${method.toLowerCase()}${pascal}`);
880
+ const executeName = input.executeName || (method === 'GET' ? `load${pascal}` : `${normalizedOperation.replace(/(^|_)([a-z])/g, (_m, _p, ch) => ch.toUpperCase()).replace(/^./, (ch) => ch.toLowerCase())}${pascal}Api`);
858
881
  const refreshName = input.refreshName || `refresh${pascal}`;
859
882
  const options = [];
860
883
  if (method !== 'GET')
@@ -878,6 +901,60 @@ export function buildExtensionApiUsageSnippet(input = {}) {
878
901
  lines.push(`onMounted(() => { ${executeName}(); });`);
879
902
  }
880
903
  }
904
+ else if (normalizedOperation === 'create') {
905
+ const handlerName = input.handlerName || `create${pascal.replace(/s$/, '')}`;
906
+ const payloadName = input.payloadName || 'payload';
907
+ lines.push(...[
908
+ '',
909
+ `async function ${handlerName}(${payloadName}) {`,
910
+ ` const response = await ${executeName}({ body: ${payloadName} });`,
911
+ ' if (!response) return null;',
912
+ ' return response;',
913
+ '}',
914
+ ]);
915
+ }
916
+ else if (normalizedOperation === 'update') {
917
+ const handlerName = input.handlerName || `update${pascal.replace(/s$/, '')}`;
918
+ const recordName = input.recordName || 'record';
919
+ const bodyName = input.bodyName || 'body';
920
+ const idExpression = input.idExpression || `${recordName}.id`;
921
+ const bodyArg = bodyName === 'body' ? 'body' : `body: ${bodyName}`;
922
+ lines.push(...[
923
+ '',
924
+ `async function ${handlerName}(${recordName}, ${bodyName}) {`,
925
+ ` const response = await ${executeName}({ id: ${idExpression}, ${bodyArg} });`,
926
+ ' if (!response) return null;',
927
+ ' return response;',
928
+ '}',
929
+ ]);
930
+ }
931
+ else if (normalizedOperation === 'delete') {
932
+ const handlerName = input.handlerName || `delete${pascal.replace(/s$/, '')}`;
933
+ const recordName = input.recordName || 'record';
934
+ const idExpression = input.idExpression || `${recordName}.id`;
935
+ lines.push(...[
936
+ '',
937
+ `async function ${handlerName}(${recordName}) {`,
938
+ ` const response = await ${executeName}({ id: ${idExpression} });`,
939
+ ' if (!response) return null;',
940
+ ' return response;',
941
+ '}',
942
+ ]);
943
+ }
944
+ else if (normalizedOperation === 'batch_update' || normalizedOperation === 'batch_delete') {
945
+ const handlerName = input.handlerName || `${normalizedOperation === 'batch_update' ? 'update' : 'delete'}${pascal}Batch`;
946
+ const idsName = input.idsName || 'ids';
947
+ const bodyName = input.bodyName || 'body';
948
+ const args = normalizedOperation === 'batch_update' ? `{ ids: ${idsName}, body: ${bodyName} }` : `{ ids: ${idsName} }`;
949
+ lines.push(...[
950
+ '',
951
+ `async function ${handlerName}(${normalizedOperation === 'batch_update' ? `${idsName}, ${bodyName}` : idsName}) {`,
952
+ ` const response = await ${executeName}(${args});`,
953
+ ' if (!response) return null;',
954
+ ' return response;',
955
+ '}',
956
+ ]);
957
+ }
881
958
  else {
882
959
  const handlerName = input.handlerName || `${method.toLowerCase()}${pascal}Record`;
883
960
  lines.push(...[
@@ -891,9 +968,11 @@ export function buildExtensionApiUsageSnippet(input = {}) {
891
968
  }
892
969
  return {
893
970
  action: 'extension_api_usage_built',
971
+ operation: normalizedOperation,
894
972
  snippet: lines.join('\n'),
895
973
  contract: [
896
974
  'useApi returns refs plus execute/refresh; it does not auto-run.',
975
+ 'The useApi path is the base route string or a () => string getter; do not pass computed refs and do not put :id placeholders in the path.',
897
976
  'Pass query/body as objects or computed objects, not JSON.stringify strings.',
898
977
  'Read normal list rows from data.value?.data or from the direct execute() response.',
899
978
  'For mutations, call execute({ body }), execute({ id, body }), execute({ id }), or execute({ ids }) from a user action.',
@@ -1764,19 +1843,6 @@ async function patchExtensionCode(apiUrl, { id, name, search, replace, searchMod
1764
1843
  validation: result.validation,
1765
1844
  };
1766
1845
  }
1767
- function normalizeMetadataTables(metadata) {
1768
- const tables = metadata?.data?.tables || metadata?.tables || metadata?.data || [];
1769
- return Array.isArray(tables) ? tables : Object.values(tables || {});
1770
- }
1771
- async function getMetadataTables(apiUrl) {
1772
- return normalizeMetadataTables(await fetchAPI(apiUrl, '/metadata'));
1773
- }
1774
- function resolveTable(tables, tableName) {
1775
- const table = tables.find((item) => item?.name === tableName || item?.alias === tableName || sameId(getId(item), tableName));
1776
- if (!table)
1777
- throw new Error(`Table not found: ${tableName}`);
1778
- return table;
1779
- }
1780
1846
  function resolveColumn(table, columnName) {
1781
1847
  const column = (table.columns || []).find((item) => item?.name === columnName || sameId(getId(item), columnName));
1782
1848
  if (!column)
@@ -2860,6 +2926,36 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
2860
2926
  assertExtensionKnowledgeAck(extensionKnowledgeAckKey);
2861
2927
  return jsonText(buildExtensionUiSnippet(kind, input));
2862
2928
  });
2929
+ server.tool('build_extension_api_usage', [
2930
+ 'Generate a contract-safe useApi snippet for Enfyra admin extensions.',
2931
+ 'Use this instead of writing useApi calls from memory so route paths, execute({ id, body }), query/body objects, and mutation handlers follow the app composable contract.',
2932
+ 'The tool returns code only; apply it with patch_extension_code or update_extension_code and then validate/save normally.',
2933
+ ].join(' '), {
2934
+ operation: z.enum(['list', 'find_one', 'create', 'update', 'delete', 'batch_update', 'batch_delete']).default('list').describe('API usage pattern to generate. Reads use the base route with query objects; mutations append ids through execute options.'),
2935
+ resource: z.string().default('items').describe('Resource variable base name, e.g. notes, projects, messages.'),
2936
+ path: z.string().optional().describe('Base API route path such as /notes. Do not include /:id; the builder strips a trailing /:id if provided.'),
2937
+ queryExpression: z.string().optional().describe('Raw Vue expression for query object/computed. Do not JSON.stringify.'),
2938
+ bodyExpression: z.string().optional().describe('Raw Vue expression for default body object/computed when useful. Do not JSON.stringify.'),
2939
+ errorContext: z.string().optional().describe('Safe error context label for useApi error reporting.'),
2940
+ responseName: z.string().optional().describe('Optional data ref variable name.'),
2941
+ pendingName: z.string().optional().describe('Optional pending ref variable name.'),
2942
+ errorName: z.string().optional().describe('Optional error ref variable name.'),
2943
+ executeName: z.string().optional().describe('Optional execute alias name.'),
2944
+ refreshName: z.string().optional().describe('Optional refresh alias name.'),
2945
+ rowsName: z.string().optional().describe('Optional computed rows variable for list/find_one operations.'),
2946
+ handlerName: z.string().optional().describe('Optional generated handler function name for mutations.'),
2947
+ recordName: z.string().optional().describe('Record parameter name for update/delete handlers.'),
2948
+ payloadName: z.string().optional().describe('Payload parameter name for create handlers.'),
2949
+ bodyName: z.string().optional().describe('Body parameter name for update/batch_update handlers.'),
2950
+ idsName: z.string().optional().describe('Ids parameter name for batch handlers.'),
2951
+ idExpression: z.string().optional().describe('Raw id expression for update/delete handlers. Defaults to record.id.'),
2952
+ autoLoad: z.boolean().optional().default(true).describe('For reads, generate onMounted(() => execute()).'),
2953
+ onErrorExpression: z.string().optional().describe('Raw onError handler expression when custom handling is needed.'),
2954
+ extensionKnowledgeAckKey: extensionKnowledgeAckParam(z),
2955
+ }, async ({ extensionKnowledgeAckKey, ...input }) => {
2956
+ assertExtensionKnowledgeAck(extensionKnowledgeAckKey);
2957
+ return jsonText(buildExtensionApiUsageSnippet(input));
2958
+ });
2863
2959
  server.tool('build_extension_drawer', [
2864
2960
  'Generate a contract-safe CommonDrawer Vue snippet for Enfyra admin extensions.',
2865
2961
  'Use this before writing or patching drawer/editing workflows so the model does not have to remember CommonDrawer slots, footer action props, full-width fields, or button type rules.',
@@ -3079,7 +3175,10 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
3079
3175
  globalRulesAckKey: globalRulesAckParam(z),
3080
3176
  }, async ({ tableName, isEnabled, globalRulesAckKey }) => {
3081
3177
  assertGlobalRulesAck(globalRulesAckKey);
3082
- const table = resolveTable(await getMetadataTables(ENFYRA_API_URL), tableName);
3178
+ const catalog = await fetchTableCatalog(ENFYRA_API_URL);
3179
+ const table = resolveTableCatalogEntry(catalog, tableName);
3180
+ if (!table)
3181
+ throw new Error(`Table not found: ${tableName}`);
3083
3182
  const existing = await findRecord(ENFYRA_API_URL, 'enfyra_graphql', { table: { id: { _eq: getId(table) } } }, 'id,_id,table.id,isEnabled');
3084
3183
  const operation = await createOrPatch(ENFYRA_API_URL, 'enfyra_graphql', existing, {
3085
3184
  table: { id: getId(table) },
@@ -3367,7 +3466,7 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
3367
3466
  globalRulesAckKey: globalRulesAckParam(z),
3368
3467
  }, async ({ tableName, columnName, ruleType, value, message, description, isEnabled, globalRulesAckKey }) => {
3369
3468
  assertGlobalRulesAck(globalRulesAckKey);
3370
- const table = resolveTable(await getMetadataTables(ENFYRA_API_URL), tableName);
3469
+ const table = await fetchTableMetadataByRef(ENFYRA_API_URL, tableName);
3371
3470
  const column = resolveColumn(table, columnName);
3372
3471
  const existing = await findRecord(ENFYRA_API_URL, 'enfyra_column_rule', {
3373
3472
  column: { id: { _eq: getId(column) } },
@@ -3407,11 +3506,10 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
3407
3506
  if (!!columnName === !!relationName)
3408
3507
  throw new Error('Provide exactly one of columnName or relationName.');
3409
3508
  assertOneScope({ roleId, roleName, allowedUserIds });
3410
- const [tables, role] = await Promise.all([
3411
- getMetadataTables(ENFYRA_API_URL),
3509
+ const [table, role] = await Promise.all([
3510
+ fetchTableMetadataByRef(ENFYRA_API_URL, tableName),
3412
3511
  resolveRole(ENFYRA_API_URL, { roleId, roleName }),
3413
3512
  ]);
3414
- const table = resolveTable(tables, tableName);
3415
3513
  const field = columnName ? resolveColumn(table, columnName) : resolveRelation(table, relationName);
3416
3514
  const filter = {
3417
3515
  action: { _eq: action },