@enfyra/mcp-server 0.1.10 → 0.1.11

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
@@ -9,24 +9,26 @@ This package is the MCP bridge only. Assistant rules, schema behavior, dynamic s
9
9
  From your project root:
10
10
 
11
11
  ```bash
12
- npx @enfyra/mcp-server config
12
+ npx @enfyra/mcp-server@latest config
13
13
  ```
14
14
 
15
15
  The config command writes project config for Codex, Claude Code, Cursor, VS Code / GitHub Copilot, and Google Antigravity. It preserves other MCP servers and replaces only the `enfyra` entry.
16
16
 
17
17
  Interactive setup asks for your Enfyra app/admin URL, then guides you to the token page when needed and asks for `ENFYRA_API_TOKEN`.
18
18
 
19
+ Generated MCP host configs run `npx -y @enfyra/mcp-server@latest` so every host start resolves the current npm `latest` dist-tag published by Enfyra.
20
+
19
21
  ```bash
20
22
  # Non-interactive, all supported clients
21
- npx @enfyra/mcp-server config --yes \
23
+ npx @enfyra/mcp-server@latest config --yes \
22
24
  --app-url http://localhost:3000 \
23
25
  -t efy_pat_your-token
24
26
 
25
27
  # One or more clients
26
- npx @enfyra/mcp-server config --codex
27
- npx @enfyra/mcp-server config --cursor --claude-code
28
- npx @enfyra/mcp-server config --vscode
29
- npx @enfyra/mcp-server config --antigravity
28
+ npx @enfyra/mcp-server@latest config --codex
29
+ npx @enfyra/mcp-server@latest config --cursor --claude-code
30
+ npx @enfyra/mcp-server@latest config --vscode
31
+ npx @enfyra/mcp-server@latest config --antigravity
30
32
  ```
31
33
 
32
34
  Equivalent in this repo:
@@ -39,18 +41,18 @@ yarn mcp:config
39
41
 
40
42
  | Client | Command | Project config |
41
43
  |--------|---------|----------------|
42
- | Codex | `npx @enfyra/mcp-server config --codex` | `.codex/config.toml` |
43
- | Claude Code | `npx @enfyra/mcp-server config --claude-code` | `.mcp.json` |
44
- | Cursor | `npx @enfyra/mcp-server config --cursor` | `.cursor/mcp.json` |
45
- | VS Code / GitHub Copilot | `npx @enfyra/mcp-server config --vscode` | `.vscode/mcp.json` |
46
- | Google Antigravity | `npx @enfyra/mcp-server config --antigravity` | `.agents/mcp_config.json` |
44
+ | Codex | `npx @enfyra/mcp-server@latest config --codex` | `.codex/config.toml` |
45
+ | Claude Code | `npx @enfyra/mcp-server@latest config --claude-code` | `.mcp.json` |
46
+ | Cursor | `npx @enfyra/mcp-server@latest config --cursor` | `.cursor/mcp.json` |
47
+ | VS Code / GitHub Copilot | `npx @enfyra/mcp-server@latest config --vscode` | `.vscode/mcp.json` |
48
+ | Google Antigravity | `npx @enfyra/mcp-server@latest config --antigravity` | `.agents/mcp_config.json` |
47
49
  | MCP Inspector / other project-scoped hosts | Paste the shared STDIO config below | Host-specific project config |
48
50
 
49
51
  <details>
50
52
  <summary><strong>Codex setup</strong></summary>
51
53
 
52
54
  ```bash
53
- npx @enfyra/mcp-server config --codex
55
+ npx @enfyra/mcp-server@latest config --codex
54
56
  ```
55
57
 
56
58
  Generated project config:
@@ -58,7 +60,7 @@ Generated project config:
58
60
  ```toml
59
61
  [mcp_servers.enfyra]
60
62
  command = "npx"
61
- args = ["-y", "@enfyra/mcp-server"]
63
+ args = ["-y", "@enfyra/mcp-server@latest"]
62
64
 
63
65
  [mcp_servers.enfyra.env]
64
66
  ENFYRA_API_URL = "http://localhost:3000/api"
@@ -77,7 +79,7 @@ Official reference: [Codex config](https://developers.openai.com/codex/config-re
77
79
  <summary><strong>Claude Code setup</strong></summary>
78
80
 
79
81
  ```bash
80
- npx @enfyra/mcp-server config --claude-code
82
+ npx @enfyra/mcp-server@latest config --claude-code
81
83
  ```
82
84
 
83
85
  Project config is written to `.mcp.json`. MCP server definitions do not belong in `.claude/settings.json`.
@@ -88,7 +90,7 @@ Claude Code also supports its own CLI:
88
90
  claude mcp add --transport stdio --scope project \
89
91
  --env ENFYRA_API_URL=http://localhost:3000/api \
90
92
  --env ENFYRA_API_TOKEN=efy_pat_your-token \
91
- enfyra -- npx -y @enfyra/mcp-server
93
+ enfyra -- npx -y @enfyra/mcp-server@latest
92
94
  ```
93
95
 
94
96
  Scope precedence when the same server name exists in multiple places is local, then project, then user. Project-scoped `.mcp.json` may require approval in Claude Code.
@@ -101,7 +103,7 @@ Official references: [Claude Code MCP](https://docs.anthropic.com/en/docs/claude
101
103
  <summary><strong>Cursor setup</strong></summary>
102
104
 
103
105
  ```bash
104
- npx @enfyra/mcp-server config --cursor
106
+ npx @enfyra/mcp-server@latest config --cursor
105
107
  ```
106
108
 
107
109
  Cursor project config is written to `.cursor/mcp.json`. Global config is `~/.cursor/mcp.json` on macOS/Linux or `%USERPROFILE%\.cursor\mcp.json` on Windows.
@@ -116,7 +118,7 @@ Official reference: [Cursor MCP](https://cursor.com/docs/context/mcp).
116
118
  <summary><strong>VS Code / GitHub Copilot setup</strong></summary>
117
119
 
118
120
  ```bash
119
- npx @enfyra/mcp-server config --vscode
121
+ npx @enfyra/mcp-server@latest config --vscode
120
122
  ```
121
123
 
122
124
  VS Code workspace config is written to `.vscode/mcp.json`:
@@ -127,7 +129,7 @@ VS Code workspace config is written to `.vscode/mcp.json`:
127
129
  "enfyra": {
128
130
  "type": "stdio",
129
131
  "command": "npx",
130
- "args": ["-y", "@enfyra/mcp-server"],
132
+ "args": ["-y", "@enfyra/mcp-server@latest"],
131
133
  "env": {
132
134
  "ENFYRA_API_URL": "http://localhost:3000/api",
133
135
  "ENFYRA_API_TOKEN": "efy_pat_your-token"
@@ -147,7 +149,7 @@ Official references: [VS Code MCP servers](https://code.visualstudio.com/docs/co
147
149
  <summary><strong>Google Antigravity setup</strong></summary>
148
150
 
149
151
  ```bash
150
- npx @enfyra/mcp-server config --antigravity
152
+ npx @enfyra/mcp-server@latest config --antigravity
151
153
  ```
152
154
 
153
155
  Antigravity project config is written to `.agents/mcp_config.json`:
@@ -157,7 +159,7 @@ Antigravity project config is written to `.agents/mcp_config.json`:
157
159
  "mcpServers": {
158
160
  "enfyra": {
159
161
  "command": "npx",
160
- "args": ["-y", "@enfyra/mcp-server"],
162
+ "args": ["-y", "@enfyra/mcp-server@latest"],
161
163
  "env": {
162
164
  "ENFYRA_API_URL": "http://localhost:3000/api",
163
165
  "ENFYRA_API_TOKEN": "efy_pat_your-token"
@@ -183,7 +185,7 @@ Use the shared STDIO config with any project-scoped host that accepts an `mcpSer
183
185
  "mcpServers": {
184
186
  "enfyra": {
185
187
  "command": "npx",
186
- "args": ["-y", "@enfyra/mcp-server"],
188
+ "args": ["-y", "@enfyra/mcp-server@latest"],
187
189
  "env": {
188
190
  "ENFYRA_API_URL": "http://localhost:3000/api",
189
191
  "ENFYRA_API_TOKEN": "efy_pat_your-token"
@@ -202,7 +204,7 @@ Official reference: [MCP Inspector](https://modelcontextprotocol.io/docs/tools/i
202
204
  ## Config Command
203
205
 
204
206
  ```bash
205
- npx @enfyra/mcp-server config [options]
207
+ npx @enfyra/mcp-server@latest config [options]
206
208
  ```
207
209
 
208
210
  | Option | Use |
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "@enfyra/mcp-server",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
4
4
  "description": "MCP server for Enfyra - manage Enfyra instances from MCP-compatible coding tools",
5
5
  "type": "module",
6
+ "packageManager": "yarn@4.17.0",
6
7
  "license": "MIT",
7
8
  "main": "src/index.mjs",
8
9
  "bin": {
9
- "mcp-server": "src/index.mjs",
10
- "enfyra-mcp-server": "src/index.mjs"
10
+ "enfyra-mcp-server": "src/index.mjs",
11
+ "mcp-server": "src/index.mjs"
11
12
  },
12
13
  "files": [
13
14
  "src"
@@ -26,4 +27,4 @@
26
27
  "publishConfig": {
27
28
  "access": "public"
28
29
  }
29
- }
30
+ }
package/src/index.mjs CHANGED
@@ -10,8 +10,8 @@ if (args[0] === '--help' || args[0] === '-h' || args[0] === 'help') {
10
10
  console.log(`Enfyra MCP Server
11
11
 
12
12
  Usage:
13
- npx @enfyra/mcp-server Start the MCP stdio server
14
- npx @enfyra/mcp-server config [flags] Write project-local MCP host config
13
+ npx @enfyra/mcp-server@latest Start the MCP stdio server
14
+ npx @enfyra/mcp-server@latest config [flags] Write project-local MCP host config
15
15
 
16
16
  Common config flags:
17
17
  --codex Write ./.codex/config.toml
@@ -25,7 +25,7 @@ Common config flags:
25
25
  -t, --api-token ENFYRA_API_TOKEN
26
26
  -h, --help Show config help
27
27
 
28
- Run \`npx @enfyra/mcp-server config --help\` for full config details.
28
+ Run \`npx @enfyra/mcp-server@latest config --help\` for full config details.
29
29
  `);
30
30
  process.exit(0);
31
31
  }
@@ -5,6 +5,7 @@ import { stdin as input, stdout as output, cwd } from 'node:process';
5
5
  import { dirname, join } from 'node:path';
6
6
 
7
7
  const SERVER_KEY = 'enfyra';
8
+ const MCP_PACKAGE_SPEC = '@enfyra/mcp-server@latest';
8
9
  const forceColor = process.env.FORCE_COLOR != null && process.env.FORCE_COLOR !== '0';
9
10
  const canStyle = forceColor || (output.isTTY && process.env.NO_COLOR == null);
10
11
  const style = {
@@ -67,7 +68,7 @@ function printHelp() {
67
68
  ${style.dim('Write project-local MCP client config for Enfyra.')}
68
69
 
69
70
  ${style.bold('Usage')}
70
- npx @enfyra/mcp-server config [options]
71
+ npx @enfyra/mcp-server@latest config [options]
71
72
 
72
73
  ${style.bold('Supported clients')}
73
74
  Codex ./.codex/config.toml
@@ -99,15 +100,15 @@ ${style.bold('Interactive mode')}
99
100
  Existing Enfyra config and environment variables are used as defaults. Re-run anytime to update.
100
101
 
101
102
  ${style.bold('Examples')}
102
- npx @enfyra/mcp-server config
103
- npx @enfyra/mcp-server config --yes
104
- npx @enfyra/mcp-server config --codex --cursor
105
- npx @enfyra/mcp-server config --vscode
106
- npx @enfyra/mcp-server config --antigravity
107
- npx @enfyra/mcp-server config --claude-code
108
- npx @enfyra/mcp-server config --reconfig
109
- npx @enfyra/mcp-server config --app-url http://localhost:3000 -t 'efy_pat_...'
110
- ENFYRA_APP_URL=https://demo.enfyra.io ENFYRA_API_TOKEN=efy_pat_... npx @enfyra/mcp-server config --yes
103
+ npx @enfyra/mcp-server@latest config
104
+ npx @enfyra/mcp-server@latest config --yes
105
+ npx @enfyra/mcp-server@latest config --codex --cursor
106
+ npx @enfyra/mcp-server@latest config --vscode
107
+ npx @enfyra/mcp-server@latest config --antigravity
108
+ npx @enfyra/mcp-server@latest config --claude-code
109
+ npx @enfyra/mcp-server@latest config --reconfig
110
+ npx @enfyra/mcp-server@latest config --app-url http://localhost:3000 -t 'efy_pat_...'
111
+ ENFYRA_APP_URL=https://demo.enfyra.io ENFYRA_API_TOKEN=efy_pat_... npx @enfyra/mcp-server@latest config --yes
111
112
  `);
112
113
  }
113
114
 
@@ -170,7 +171,7 @@ function parseArgs(argv) {
170
171
  function buildServerEntry(apiUrl, apiToken) {
171
172
  return {
172
173
  command: 'npx',
173
- args: ['-y', '@enfyra/mcp-server'],
174
+ args: ['-y', MCP_PACKAGE_SPEC],
174
175
  env: {
175
176
  ENFYRA_API_URL: apiUrl,
176
177
  ENFYRA_API_TOKEN: apiToken,
@@ -256,7 +257,7 @@ function buildCodexTomlBlock(apiUrl, apiToken) {
256
257
  return [
257
258
  '[mcp_servers.enfyra]',
258
259
  'command = "npx"',
259
- 'args = ["-y", "@enfyra/mcp-server"]',
260
+ `args = ["-y", "${MCP_PACKAGE_SPEC}"]`,
260
261
  '',
261
262
  '[mcp_servers.enfyra.env]',
262
263
  `ENFYRA_API_URL = ${tomlString(apiUrl)}`,
@@ -667,13 +667,13 @@ update_record({
667
667
  ]),
668
668
  uniques: JSON.stringify([["message", "member"]]),
669
669
  indexes: JSON.stringify([
670
- ["member", "isRead", "conversation"],
671
- ["conversation", "member", "isRead"]
670
+ ["conversation", "isRead"]
672
671
  ])
673
672
  })`,
674
673
  notes: [
675
674
  'Unread is per user and per message; do not put global read state on conversation.',
676
- 'readAt is a datetime field and gets its own auto index; the explicit indexes here are compound unread lookup indexes.',
675
+ 'message and member appear in the unique constraint, so they must not be added to indexes; unique fields are already indexed by the unique constraint.',
676
+ 'readAt is a datetime field and gets its own auto index; explicit indexes should cover only non-unique hot lookup fields.',
677
677
  'For chat-list UX, default to a boolean unread dot instead of exact counts.',
678
678
  ],
679
679
  },
@@ -1,8 +1,8 @@
1
- export const GLOBAL_RULES_ACK_KEY = 'EFYRA::GLOBAL-RULES::READ-LIVE-CONTEXT-FIRST::2bW-20260701';
1
+ export const GLOBAL_RULES_ACK_KEY = 'EFYRA::GLOBAL-RULES::UNIQUE-FIELDS-ARE-INDEXED::2bW-20260701B';
2
2
  export const DYNAMIC_CODE_KNOWLEDGE_ACK_KEY = 'EFYRA::SECURE-REPO-CONTRACT::R9x-kelp-42Q::NO-RAW-TRUSTED';
3
3
  export const EXTENSION_KNOWLEDGE_ACK_KEY = 'EFYRA::EXTENSION-THEME-CONTRACT::VIOLET-IS-NOT-A-PLAN::7mQ';
4
4
 
5
- const REQUIRED_KNOWLEDGE_VERSION = '2026-07-01.global-rules-v1';
5
+ const REQUIRED_KNOWLEDGE_VERSION = '2026-07-01.global-rules-v2';
6
6
 
7
7
  export function globalRulesAckParam(z) {
8
8
  return z.string().describe('Required global-rules acknowledgement key from get_enfyra_required_knowledge. Call that tool, read the global Enfyra MCP rules, then pass globalRulesAckKey exactly.');
@@ -84,6 +84,15 @@ export function buildRequiredKnowledgePayload() {
84
84
  'Never fabricate ids, field names, relation names, paths, package names, or permission scopes.',
85
85
  ],
86
86
  },
87
+ {
88
+ id: 'schema-constraints',
89
+ rules: [
90
+ 'For table schema, a field that appears in any uniques group must not appear in indexes.',
91
+ 'A unique constraint already creates the indexed unique lookup for its fields.',
92
+ 'Use uniques for data integrity and indexes only for non-unique query-performance fields that are not already unique.',
93
+ 'Before create_table or update_table with indexes/uniques, inspect the current table and remove indexes that reference unique fields.',
94
+ ],
95
+ },
87
96
  {
88
97
  id: 'security-first',
89
98
  rules: [
@@ -125,6 +125,37 @@ function normalizeConstraintGroups(name, groups) {
125
125
  });
126
126
  }
127
127
 
128
+ function parseConstraintGroupsParam(name, value) {
129
+ return normalizeConstraintGroups(name, parseJsonArrayParam(name, value));
130
+ }
131
+
132
+ function normalizeConstraintGroupsValue(name, value) {
133
+ if (value == null) return [];
134
+ const parsed = typeof value === 'string' ? JSON.parse(value) : value;
135
+ if (!Array.isArray(parsed)) {
136
+ throw new Error(`${name} must be a JSON array.`);
137
+ }
138
+ return normalizeConstraintGroups(name, parsed);
139
+ }
140
+
141
+ export function assertIndexesDoNotReferenceUniqueFields(indexes, uniques) {
142
+ const uniqueFields = new Set(uniques.flat());
143
+ const conflicts = indexes
144
+ .map((group) => ({
145
+ index: group,
146
+ uniqueFields: group.filter((field) => uniqueFields.has(field)),
147
+ }))
148
+ .filter((conflict) => conflict.uniqueFields.length > 0);
149
+ if (conflicts.length > 0) {
150
+ const groups = conflicts
151
+ .map((conflict) => `${JSON.stringify(conflict.index)} uses unique field(s) ${JSON.stringify(conflict.uniqueFields)}`)
152
+ .join('; ');
153
+ throw new Error(
154
+ `Invalid schema constraints: indexes must not include fields that are already unique. Conflict(s): ${groups}. Unique constraints already create indexed lookups; remove unique fields from indexes and keep those fields only in uniques.`,
155
+ );
156
+ }
157
+ }
158
+
128
159
  export function normalizeRelationForTablePatch(relation) {
129
160
  for (const key of FORBIDDEN_RELATION_KEYS) {
130
161
  if (Object.prototype.hasOwnProperty.call(relation, key)) {
@@ -566,6 +597,7 @@ export function registerTableTools(server, ENFYRA_API_URL) {
566
597
  '**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).',
567
598
  '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.',
568
599
  '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.',
600
+ 'A field that appears in any `uniques` group must not appear in `indexes`; unique constraints already create indexed unique lookups.',
569
601
  '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, {id}, or an exact table name that MCP resolves to an id before mutation. Omit inversePropertyName unless a concrete parent-to-child query or UI surface needs it.',
570
602
  'Do NOT provide physical FK/junction columns. Never include fkCol, fkColumn, foreignKeyColumn, sourceColumn, targetColumn, junctionSourceColumn, or junctionTargetColumn. Enfyra derives and hides those physical columns from relation propertyName/table metadata.',
571
603
  'Schema operations (create/update/delete table, add column) must run one at a time — migration locks DB; parallel calls will fail.',
@@ -583,7 +615,7 @@ export function registerTableTools(server, ENFYRA_API_URL) {
583
615
  isSingleRecord: z.boolean().optional().describe('Set to true for single-record tables such as settings/config. This is passed directly to enfyra_table create.'),
584
616
  columns: z.string().optional().describe('JSON array of column definitions to create with the table (cascade). Each column: { name, type, isNullable?, isUnique?, isPublished?, isUpdatable?, isEncrypted?, defaultValue?, description?, options? }. Set isEncrypted=true for values encrypted at rest; set isUpdatable=false separately only when the field should be immutable. The `id` column is always auto-included. Example: [{"name":"title","type":"varchar"},{"name":"api_key","type":"varchar","isEncrypted":true,"isPublished":false}]'),
585
617
  relations: z.string().optional().describe('JSON array of relation definitions to create with the table in the same cascade call. Each relation: { targetTable, type, propertyName, inversePropertyName?, mappedBy?, isNullable?, onDelete?, description? }. targetTable can be an id, {"id": <id>}, or an exact table name that MCP resolves to an id before mutation. Do not include physical FK/junction columns such as fkCol, foreignKeyColumn, sourceColumn, targetColumn, junctionSourceColumn, or junctionTargetColumn; Enfyra derives them and hides FK columns from app schema. Omit inversePropertyName unless a concrete response, UI, deep query, aggregate sort/count, or parent-to-child traversal needs the reverse field. Example only when parent posts are queried: [{"targetTable":2,"type":"many-to-one","propertyName":"author","inversePropertyName":"posts","isNullable":false,"onDelete":"CASCADE"}]'),
586
- indexes: z.string().optional().describe('JSON array of logical index field groups. Each group can be ["fieldA","fieldB"] or {"value":["fieldA","fieldB"]}. Relation property names are allowed. Example: [["member","isRead","conversation"],["conversation","member","isRead"]]'),
618
+ indexes: z.string().optional().describe('JSON array of logical non-unique index field groups. Each group can be ["fieldA","fieldB"] or {"value":["fieldA","fieldB"]}. Relation property names are allowed. Do not include any field that appears in uniques; unique constraints already create indexed unique lookups. Example: [["status","createdAt"]]'),
587
619
  uniques: z.string().optional().describe('JSON array of logical unique field groups. Each group can be ["fieldA","fieldB"] or {"value":["fieldA","fieldB"]}. Example: [["message","member"]]'),
588
620
  globalRulesAckKey: globalRulesAckParam(z),
589
621
  },
@@ -596,8 +628,9 @@ export function registerTableTools(server, ENFYRA_API_URL) {
596
628
  const userRelations = metadata
597
629
  ? resolveRelationTargetsFromMetadata(metadata, parsedRelations)
598
630
  : parsedRelations;
599
- const indexes = normalizeConstraintGroups('indexes', parseJsonArrayParam('indexes', indexesJson));
600
- const uniques = normalizeConstraintGroups('uniques', parseJsonArrayParam('uniques', uniquesJson));
631
+ const indexes = parseConstraintGroupsParam('indexes', indexesJson);
632
+ const uniques = parseConstraintGroupsParam('uniques', uniquesJson);
633
+ assertIndexesDoNotReferenceUniqueFields(indexes, uniques);
601
634
  const body = { name, description, columns: [idColumn, ...userColumns], relations: userRelations };
602
635
  if (isSingleRecord !== undefined) body.isSingleRecord = isSingleRecord;
603
636
  if (indexesJson !== undefined) body.indexes = indexes;
@@ -654,6 +687,7 @@ export function registerTableTools(server, ENFYRA_API_URL) {
654
687
  'Update table properties: name (rename), alias, description, isSingleRecord, graphqlEnabled, indexes, and uniques.',
655
688
  'Does NOT modify columns or relations — use create_column, update_column, delete_column, create_relation for those.',
656
689
  'When passing `indexes` or `uniques`, pass the complete desired array of logical field groups; omitted fields are preserved. Relation property names are allowed and are resolved by Enfyra. Example indexes: [["member","isRead","conversation"],["conversation","member","isRead"]].',
690
+ 'A field that appears in any `uniques` group must not appear in `indexes`; unique constraints already create indexed unique lookups.',
657
691
  'Run schema changes sequentially — migration locks DB per operation.',
658
692
  ].join(' '),
659
693
  {
@@ -663,7 +697,7 @@ export function registerTableTools(server, ENFYRA_API_URL) {
663
697
  description: z.string().optional().describe('New description.'),
664
698
  isSingleRecord: z.boolean().optional().describe('Set to true for single-record table (e.g., settings/config).'),
665
699
  graphqlEnabled: z.boolean().optional().describe('Enable or disable GraphQL for this table by syncing enfyra_graphql.isEnabled. GraphQL table data still requires Bearer auth; anonymous root or schema probes may return 200.'),
666
- indexes: z.string().optional().describe('Complete JSON array of logical index field groups to store on enfyra_table.indexes. Each group can be ["fieldA","fieldB"] or {"value":["fieldA","fieldB"]}. Omit to preserve current indexes; pass [] to clear.'),
700
+ indexes: z.string().optional().describe('Complete JSON array of logical non-unique index field groups to store on enfyra_table.indexes. Each group can be ["fieldA","fieldB"] or {"value":["fieldA","fieldB"]}. Omit to preserve current indexes; pass [] to clear. Do not include any field that appears in uniques; unique constraints already create indexed unique lookups.'),
667
701
  uniques: z.string().optional().describe('Complete JSON array of logical unique field groups to store on enfyra_table.uniques. Each group can be ["fieldA","fieldB"] or {"value":["fieldA","fieldB"]}. Omit to preserve current uniques; pass [] to clear.'),
668
702
  globalRulesAckKey: globalRulesAckParam(z),
669
703
  },
@@ -675,8 +709,19 @@ export function registerTableTools(server, ENFYRA_API_URL) {
675
709
  if (description !== undefined) body.description = description;
676
710
  if (isSingleRecord !== undefined) body.isSingleRecord = isSingleRecord;
677
711
  if (graphqlEnabled !== undefined) body.graphqlEnabled = graphqlEnabled;
678
- if (indexesJson !== undefined) body.indexes = normalizeConstraintGroups('indexes', parseJsonArrayParam('indexes', indexesJson));
679
- if (uniquesJson !== undefined) body.uniques = normalizeConstraintGroups('uniques', parseJsonArrayParam('uniques', uniquesJson));
712
+ if (indexesJson !== undefined) body.indexes = parseConstraintGroupsParam('indexes', indexesJson);
713
+ if (uniquesJson !== undefined) body.uniques = parseConstraintGroupsParam('uniques', uniquesJson);
714
+
715
+ if (indexesJson !== undefined || uniquesJson !== undefined) {
716
+ let indexes = body.indexes;
717
+ let uniques = body.uniques;
718
+ if (indexes === undefined || uniques === undefined) {
719
+ const existing = await fetchTableWithDetails(ENFYRA_API_URL, tableId);
720
+ if (indexes === undefined) indexes = normalizeConstraintGroupsValue('indexes', existing.indexes);
721
+ if (uniques === undefined) uniques = normalizeConstraintGroupsValue('uniques', existing.uniques);
722
+ }
723
+ assertIndexesDoNotReferenceUniqueFields(indexes ?? [], uniques ?? []);
724
+ }
680
725
 
681
726
  const result = await patchTableAutoConfirm(ENFYRA_API_URL, tableId, body);
682
727
  return jsonContent({