d365fo-mcp 1.2.0 → 1.3.0

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.
@@ -1009,6 +1009,9 @@ const BRIDGE_MODIFY_OPS = new Set([
1009
1009
  'add-field', 'modify-field', 'rename-field', 'replace-all-fields', 'remove-field',
1010
1010
  'add-index', 'remove-index',
1011
1011
  'add-relation', 'remove-relation',
1012
+ // No C# op backs these — they are served by a direct-XML writer, but they still
1013
+ // pass through the same modify gate.
1014
+ 'add-delete-action', 'remove-delete-action',
1012
1015
  'add-field-group', 'remove-field-group', 'add-field-to-field-group',
1013
1016
  'modify-property',
1014
1017
  'add-enum-value', 'modify-enum-value', 'remove-enum-value',
@@ -4,7 +4,7 @@
4
4
  * instances/rebuild-instance.ps1 minus the git-pull step (that lives in
5
5
  * `d365fo-mcp update`).
6
6
  */
7
- import { installMode, isWindows, paths } from '../context.js';
7
+ import { dataRoot, installMode, isWindows, paths } from '../context.js';
8
8
  import { runNode } from '../exec.js';
9
9
  import { listInstances } from '../instances.js';
10
10
  import { instanceTarget, pickTarget, rootTarget, targetEnv } from '../target.js';
@@ -25,18 +25,24 @@ function scriptArgs(tsSource, bundle) {
25
25
  /** Run extract + build-database for one target. Returns true on success. */
26
26
  export async function rebuildIndex(target) {
27
27
  const env = targetEnv(target);
28
+ // Run from the target's own directory, not the package: the index scripts
29
+ // fall back to relative literals ('./data/xpp-metadata.db') whenever a
30
+ // setting is absent AND no config file exists to anchor it, and the default
31
+ // cwd of runNode is repoRoot — which for an npm install is the package npm
32
+ // replaces on every update, on whatever drive npm happens to live on.
33
+ const cwd = target.instance?.dir ?? dataRoot();
28
34
  if (isWindows) {
29
35
  const expanded = normalizeXppConfigName(target.store);
30
36
  if (expanded)
31
37
  p.log.info(`Expanded XPP config name: ${expanded.from} → ${expanded.to}`);
32
38
  }
33
39
  p.log.step(`[1/2] Extracting metadata (${target.label})…`);
34
- if (await runNode(scriptArgs(paths.extractScript, paths.extractScriptDist), { env }) !== 0) {
40
+ if (await runNode(scriptArgs(paths.extractScript, paths.extractScriptDist), { cwd, env }) !== 0) {
35
41
  p.log.error(`Metadata extraction failed for ${target.label}`);
36
42
  return false;
37
43
  }
38
44
  p.log.step(`[2/2] Building database (${target.label})…`);
39
- if (await runNode(['--max-old-space-size=6144', ...scriptArgs(paths.buildDbScript, paths.buildDbScriptDist)], { env }) !== 0) {
45
+ if (await runNode(['--max-old-space-size=6144', ...scriptArgs(paths.buildDbScript, paths.buildDbScriptDist)], { cwd, env }) !== 0) {
40
46
  p.log.error(`Database build failed for ${target.label}`);
41
47
  return false;
42
48
  }
@@ -41,6 +41,23 @@ export declare function configBaseDir(configPath: string): string;
41
41
  * can use portable values like ./data/xpp-metadata.db.
42
42
  */
43
43
  export declare function toEnvRecord(files: Pick<ResolvedConfigFiles, 'baseDir' | 'config' | 'secrets'>): Record<string, string>;
44
+ /**
45
+ * The path settings the wizard never writes, resolved against `baseDir`.
46
+ *
47
+ * DB_PATH, LABELS_DB_PATH and METADATA_PATH are advanced settings with
48
+ * relative defaults, so a normal setup leaves them out of the config file
49
+ * entirely and every consumer falls back to its own `'./data/…'` literal —
50
+ * which resolves from process.cwd(). For a git checkout that is the repo, and
51
+ * the answer happens to be right. For an npm install it is the *package*
52
+ * directory: `d365fo-mcp index` spawns the build scripts with cwd = repoRoot,
53
+ * so a 2 GB index landed next to the installed package, on whatever drive npm
54
+ * lives on, instead of in the installation directory the user chose in setup
55
+ * (issue: build ran out of space on C: and SQLite aborted the transaction).
56
+ *
57
+ * Emitting the defaults here pins them to the installation directory instead.
58
+ * A checkout is its own data directory, so its paths do not move.
59
+ */
60
+ export declare function defaultPathEnv(baseDir: string): Record<string, string>;
44
61
  /** Write the config file, creating its directory. Keys are emitted in registry order. */
45
62
  export declare function writeConfigFile(configPath: string, config: ConfigObject): void;
46
63
  /** Write secrets.json with owner-only permissions where the platform supports them. */
@@ -111,6 +111,31 @@ export function toEnvRecord(files) {
111
111
  }
112
112
  return out;
113
113
  }
114
+ /**
115
+ * The path settings the wizard never writes, resolved against `baseDir`.
116
+ *
117
+ * DB_PATH, LABELS_DB_PATH and METADATA_PATH are advanced settings with
118
+ * relative defaults, so a normal setup leaves them out of the config file
119
+ * entirely and every consumer falls back to its own `'./data/…'` literal —
120
+ * which resolves from process.cwd(). For a git checkout that is the repo, and
121
+ * the answer happens to be right. For an npm install it is the *package*
122
+ * directory: `d365fo-mcp index` spawns the build scripts with cwd = repoRoot,
123
+ * so a 2 GB index landed next to the installed package, on whatever drive npm
124
+ * lives on, instead of in the installation directory the user chose in setup
125
+ * (issue: build ran out of space on C: and SQLite aborted the transaction).
126
+ *
127
+ * Emitting the defaults here pins them to the installation directory instead.
128
+ * A checkout is its own data directory, so its paths do not move.
129
+ */
130
+ export function defaultPathEnv(baseDir) {
131
+ const out = {};
132
+ for (const setting of SETTINGS) {
133
+ if (setting.type !== 'path' || typeof setting.default !== 'string' || setting.default === '')
134
+ continue;
135
+ out[setting.env] = isAbsolute(setting.default) ? setting.default : resolve(baseDir, setting.default);
136
+ }
137
+ return out;
138
+ }
114
139
  /** Write the config file, creating its directory. Keys are emitted in registry order. */
115
140
  export function writeConfigFile(configPath, config) {
116
141
  fs.mkdirSync(dirname(configPath), { recursive: true });
@@ -212,6 +212,19 @@ export declare class XppSymbolIndex {
212
212
  * database (a custom-model build: ~10K of ~1.2M symbols, where the full
213
213
  * rebuild cost 327s against 5s of actual indexing work).
214
214
  */
215
+ /**
216
+ * Turn a write failure inside a model transaction into an error that names
217
+ * the database being written and, when the drive is the likely cause, how
218
+ * much room is left on it.
219
+ *
220
+ * A full disk makes SQLite roll the transaction back itself, so better-sqlite3's
221
+ * wrapper then fails to COMMIT and the only thing the user sees is
222
+ * "cannot commit - no transaction is active" with a stack inside the library —
223
+ * no path, no mention of space. That message sent at least one user hunting
224
+ * for a corrupt index when the index was simply being written to the wrong
225
+ * (and nearly full) drive.
226
+ */
227
+ private describeWriteFailure;
215
228
  indexMetadataDirectory(metadataPath: string, modelNames?: string | string[], opts?: {
216
229
  ftsStrategy?: 'rebuild' | 'incremental';
217
230
  }): Promise<void>;
@@ -1172,6 +1172,41 @@ export class XppSymbolIndex {
1172
1172
  * database (a custom-model build: ~10K of ~1.2M symbols, where the full
1173
1173
  * rebuild cost 327s against 5s of actual indexing work).
1174
1174
  */
1175
+ /**
1176
+ * Turn a write failure inside a model transaction into an error that names
1177
+ * the database being written and, when the drive is the likely cause, how
1178
+ * much room is left on it.
1179
+ *
1180
+ * A full disk makes SQLite roll the transaction back itself, so better-sqlite3's
1181
+ * wrapper then fails to COMMIT and the only thing the user sees is
1182
+ * "cannot commit - no transaction is active" with a stack inside the library —
1183
+ * no path, no mention of space. That message sent at least one user hunting
1184
+ * for a corrupt index when the index was simply being written to the wrong
1185
+ * (and nearly full) drive.
1186
+ */
1187
+ describeWriteFailure(err, model) {
1188
+ const original = err instanceof Error ? err : new Error(String(err));
1189
+ const message = original.message;
1190
+ const diskRelated = /disk is full|SQLITE_FULL|disk I\/O error|no transaction is active/i.test(message);
1191
+ if (!diskRelated)
1192
+ return original;
1193
+ let space = '';
1194
+ try {
1195
+ const stat = fs.statfsSync(path.dirname(path.resolve(this.dbPath)));
1196
+ const freeGb = (Number(stat.bavail) * Number(stat.bsize)) / 1024 ** 3;
1197
+ space = ` (${freeGb.toFixed(1)} GB free there)`;
1198
+ }
1199
+ catch {
1200
+ // statfs is best-effort — the path advice below is the useful part.
1201
+ }
1202
+ const wrapped = new Error(`Writing model '${model}' to ${this.dbPath} failed: ${message}\n` +
1203
+ `The index is written to that path${space}. A full disk makes SQLite roll the write back on its own, ` +
1204
+ `which is what surfaces as "cannot commit - no transaction is active".\n` +
1205
+ `Point the installation at a drive with room (a full index needs several GB): ` +
1206
+ `re-run 'd365fo-mcp setup' and choose another directory, or set index.dbPath / index.metadataPath in d365fo-mcp.json.`);
1207
+ wrapped.cause = original;
1208
+ return wrapped;
1209
+ }
1175
1210
  async indexMetadataDirectory(metadataPath, modelNames, opts) {
1176
1211
  const skipFts = process.env.SKIP_FTS === 'true';
1177
1212
  const resumable = process.env.RESUME === 'true';
@@ -1357,7 +1392,12 @@ export class XppSymbolIndex {
1357
1392
  // Mark model as done atomically with its data (same transaction)
1358
1393
  markProgress?.run(model, Date.now());
1359
1394
  });
1360
- tx();
1395
+ try {
1396
+ tx();
1397
+ }
1398
+ catch (err) {
1399
+ throw this.describeWriteFailure(err, model);
1400
+ }
1361
1401
  const modelDuration = ((Date.now() - modelStartTime) / 1000).toFixed(1);
1362
1402
  const elapsed = ((Date.now() - startTime) / 1000).toFixed(0);
1363
1403
  if (isCI()) {
@@ -595,6 +595,14 @@ function toEnvRecord(files) {
595
595
  }
596
596
  return out;
597
597
  }
598
+ function defaultPathEnv(baseDir) {
599
+ const out = {};
600
+ for (const setting of SETTINGS) {
601
+ if (setting.type !== "path" || typeof setting.default !== "string" || setting.default === "") continue;
602
+ out[setting.env] = isAbsolute(setting.default) ? setting.default : resolve(baseDir, setting.default);
603
+ }
604
+ return out;
605
+ }
598
606
 
599
607
  // src/utils/loadEnv.ts
600
608
  var PATH_VARS = ["DB_PATH", "LABELS_DB_PATH", "METADATA_PATH"];
@@ -629,6 +637,9 @@ function loadEnv(callerImportMetaUrl) {
629
637
  for (const [key, value] of Object.entries(toEnvRecord(files))) {
630
638
  if (!fromRealEnv.has(key) && !pinnedByEnvFile.has(key)) process.env[key] = value;
631
639
  }
640
+ for (const [key, value] of Object.entries(defaultPathEnv(files.baseDir))) {
641
+ if (!process.env[key]) process.env[key] = value;
642
+ }
632
643
  if (process.env.D365FO_DEV_ENVIRONMENT_TYPE) {
633
644
  process.env.DEV_ENVIRONMENT_TYPE = process.env.D365FO_DEV_ENVIRONMENT_TYPE;
634
645
  }
@@ -1764,13 +1775,13 @@ var XppSymbolIndex = class _XppSymbolIndex {
1764
1775
  if (this.dbPath === ":memory:") {
1765
1776
  return Promise.reject(new Error("in-memory DB is not visible to worker threads"));
1766
1777
  }
1767
- return new Promise((resolve3, reject) => {
1778
+ return new Promise((resolve4, reject) => {
1768
1779
  const worker = new Worker(new URL("./symbolCountsWorker.js", import.meta.url), {
1769
1780
  workerData: { dbPath: this.dbPath }
1770
1781
  });
1771
1782
  worker.once("message", (msg) => {
1772
1783
  if (msg.ok) {
1773
- resolve3({ total: msg.total, byType: msg.byType });
1784
+ resolve4({ total: msg.total, byType: msg.byType });
1774
1785
  } else {
1775
1786
  reject(new Error(msg.error));
1776
1787
  }
@@ -1928,6 +1939,38 @@ var XppSymbolIndex = class _XppSymbolIndex {
1928
1939
  * database (a custom-model build: ~10K of ~1.2M symbols, where the full
1929
1940
  * rebuild cost 327s against 5s of actual indexing work).
1930
1941
  */
1942
+ /**
1943
+ * Turn a write failure inside a model transaction into an error that names
1944
+ * the database being written and, when the drive is the likely cause, how
1945
+ * much room is left on it.
1946
+ *
1947
+ * A full disk makes SQLite roll the transaction back itself, so better-sqlite3's
1948
+ * wrapper then fails to COMMIT and the only thing the user sees is
1949
+ * "cannot commit - no transaction is active" with a stack inside the library —
1950
+ * no path, no mention of space. That message sent at least one user hunting
1951
+ * for a corrupt index when the index was simply being written to the wrong
1952
+ * (and nearly full) drive.
1953
+ */
1954
+ describeWriteFailure(err, model) {
1955
+ const original = err instanceof Error ? err : new Error(String(err));
1956
+ const message = original.message;
1957
+ const diskRelated = /disk is full|SQLITE_FULL|disk I\/O error|no transaction is active/i.test(message);
1958
+ if (!diskRelated) return original;
1959
+ let space = "";
1960
+ try {
1961
+ const stat2 = fs2.statfsSync(path.dirname(path.resolve(this.dbPath)));
1962
+ const freeGb = Number(stat2.bavail) * Number(stat2.bsize) / 1024 ** 3;
1963
+ space = ` (${freeGb.toFixed(1)} GB free there)`;
1964
+ } catch {
1965
+ }
1966
+ const wrapped = new Error(
1967
+ `Writing model '${model}' to ${this.dbPath} failed: ${message}
1968
+ The index is written to that path${space}. A full disk makes SQLite roll the write back on its own, which is what surfaces as "cannot commit - no transaction is active".
1969
+ Point the installation at a drive with room (a full index needs several GB): re-run 'd365fo-mcp setup' and choose another directory, or set index.dbPath / index.metadataPath in d365fo-mcp.json.`
1970
+ );
1971
+ wrapped.cause = original;
1972
+ return wrapped;
1973
+ }
1931
1974
  async indexMetadataDirectory(metadataPath, modelNames, opts) {
1932
1975
  const skipFts = process.env.SKIP_FTS === "true";
1933
1976
  const resumable = process.env.RESUME === "true";
@@ -2045,7 +2088,11 @@ var XppSymbolIndex = class _XppSymbolIndex {
2045
2088
  this.flushPropertyStats();
2046
2089
  markProgress?.run(model, Date.now());
2047
2090
  });
2048
- tx();
2091
+ try {
2092
+ tx();
2093
+ } catch (err) {
2094
+ throw this.describeWriteFailure(err, model);
2095
+ }
2049
2096
  const modelDuration = ((Date.now() - modelStartTime) / 1e3).toFixed(1);
2050
2097
  const elapsed = ((Date.now() - startTime) / 1e3).toFixed(0);
2051
2098
  if (isCI()) {
@@ -595,6 +595,14 @@ function toEnvRecord(files) {
595
595
  }
596
596
  return out;
597
597
  }
598
+ function defaultPathEnv(baseDir) {
599
+ const out = {};
600
+ for (const setting of SETTINGS) {
601
+ if (setting.type !== "path" || typeof setting.default !== "string" || setting.default === "") continue;
602
+ out[setting.env] = isAbsolute(setting.default) ? setting.default : resolve(baseDir, setting.default);
603
+ }
604
+ return out;
605
+ }
598
606
 
599
607
  // src/utils/loadEnv.ts
600
608
  var PATH_VARS = ["DB_PATH", "LABELS_DB_PATH", "METADATA_PATH"];
@@ -629,6 +637,9 @@ function loadEnv(callerImportMetaUrl) {
629
637
  for (const [key, value] of Object.entries(toEnvRecord(files))) {
630
638
  if (!fromRealEnv.has(key) && !pinnedByEnvFile.has(key)) process.env[key] = value;
631
639
  }
640
+ for (const [key, value] of Object.entries(defaultPathEnv(files.baseDir))) {
641
+ if (!process.env[key]) process.env[key] = value;
642
+ }
632
643
  if (process.env.D365FO_DEV_ENVIRONMENT_TYPE) {
633
644
  process.env.DEV_ENVIRONMENT_TYPE = process.env.D365FO_DEV_ENVIRONMENT_TYPE;
634
645
  }
@@ -1703,13 +1714,13 @@ var XppSymbolIndex = class _XppSymbolIndex {
1703
1714
  if (this.dbPath === ":memory:") {
1704
1715
  return Promise.reject(new Error("in-memory DB is not visible to worker threads"));
1705
1716
  }
1706
- return new Promise((resolve3, reject) => {
1717
+ return new Promise((resolve4, reject) => {
1707
1718
  const worker = new Worker(new URL("./symbolCountsWorker.js", import.meta.url), {
1708
1719
  workerData: { dbPath: this.dbPath }
1709
1720
  });
1710
1721
  worker.once("message", (msg) => {
1711
1722
  if (msg.ok) {
1712
- resolve3({ total: msg.total, byType: msg.byType });
1723
+ resolve4({ total: msg.total, byType: msg.byType });
1713
1724
  } else {
1714
1725
  reject(new Error(msg.error));
1715
1726
  }
@@ -1867,6 +1878,38 @@ var XppSymbolIndex = class _XppSymbolIndex {
1867
1878
  * database (a custom-model build: ~10K of ~1.2M symbols, where the full
1868
1879
  * rebuild cost 327s against 5s of actual indexing work).
1869
1880
  */
1881
+ /**
1882
+ * Turn a write failure inside a model transaction into an error that names
1883
+ * the database being written and, when the drive is the likely cause, how
1884
+ * much room is left on it.
1885
+ *
1886
+ * A full disk makes SQLite roll the transaction back itself, so better-sqlite3's
1887
+ * wrapper then fails to COMMIT and the only thing the user sees is
1888
+ * "cannot commit - no transaction is active" with a stack inside the library —
1889
+ * no path, no mention of space. That message sent at least one user hunting
1890
+ * for a corrupt index when the index was simply being written to the wrong
1891
+ * (and nearly full) drive.
1892
+ */
1893
+ describeWriteFailure(err, model) {
1894
+ const original = err instanceof Error ? err : new Error(String(err));
1895
+ const message = original.message;
1896
+ const diskRelated = /disk is full|SQLITE_FULL|disk I\/O error|no transaction is active/i.test(message);
1897
+ if (!diskRelated) return original;
1898
+ let space = "";
1899
+ try {
1900
+ const stat = fs2.statfsSync(path.dirname(path.resolve(this.dbPath)));
1901
+ const freeGb = Number(stat.bavail) * Number(stat.bsize) / 1024 ** 3;
1902
+ space = ` (${freeGb.toFixed(1)} GB free there)`;
1903
+ } catch {
1904
+ }
1905
+ const wrapped = new Error(
1906
+ `Writing model '${model}' to ${this.dbPath} failed: ${message}
1907
+ The index is written to that path${space}. A full disk makes SQLite roll the write back on its own, which is what surfaces as "cannot commit - no transaction is active".
1908
+ Point the installation at a drive with room (a full index needs several GB): re-run 'd365fo-mcp setup' and choose another directory, or set index.dbPath / index.metadataPath in d365fo-mcp.json.`
1909
+ );
1910
+ wrapped.cause = original;
1911
+ return wrapped;
1912
+ }
1870
1913
  async indexMetadataDirectory(metadataPath, modelNames, opts) {
1871
1914
  const skipFts = process.env.SKIP_FTS === "true";
1872
1915
  const resumable = process.env.RESUME === "true";
@@ -1984,7 +2027,11 @@ var XppSymbolIndex = class _XppSymbolIndex {
1984
2027
  this.flushPropertyStats();
1985
2028
  markProgress?.run(model, Date.now());
1986
2029
  });
1987
- tx();
2030
+ try {
2031
+ tx();
2032
+ } catch (err) {
2033
+ throw this.describeWriteFailure(err, model);
2034
+ }
1988
2035
  const modelDuration = ((Date.now() - modelStartTime) / 1e3).toFixed(1);
1989
2036
  const elapsed = ((Date.now() - startTime) / 1e3).toFixed(0);
1990
2037
  if (isCI()) {
@@ -595,6 +595,14 @@ function toEnvRecord(files) {
595
595
  }
596
596
  return out;
597
597
  }
598
+ function defaultPathEnv(baseDir) {
599
+ const out = {};
600
+ for (const setting of SETTINGS) {
601
+ if (setting.type !== "path" || typeof setting.default !== "string" || setting.default === "") continue;
602
+ out[setting.env] = isAbsolute(setting.default) ? setting.default : resolve(baseDir, setting.default);
603
+ }
604
+ return out;
605
+ }
598
606
 
599
607
  // src/utils/loadEnv.ts
600
608
  var PATH_VARS = ["DB_PATH", "LABELS_DB_PATH", "METADATA_PATH"];
@@ -629,6 +637,9 @@ function loadEnv(callerImportMetaUrl) {
629
637
  for (const [key, value] of Object.entries(toEnvRecord(files))) {
630
638
  if (!fromRealEnv.has(key) && !pinnedByEnvFile.has(key)) process.env[key] = value;
631
639
  }
640
+ for (const [key, value] of Object.entries(defaultPathEnv(files.baseDir))) {
641
+ if (!process.env[key]) process.env[key] = value;
642
+ }
632
643
  if (process.env.D365FO_DEV_ENVIRONMENT_TYPE) {
633
644
  process.env.DEV_ENVIRONMENT_TYPE = process.env.D365FO_DEV_ENVIRONMENT_TYPE;
634
645
  }
@@ -116,6 +116,7 @@ Model from .mcp.json; prefix auto-applied from EXTENSION_PREFIX. Classes: member
116
116
  'add-display-method', 'add-table-method',
117
117
  'add-index', 'remove-index',
118
118
  'add-relation', 'remove-relation',
119
+ 'add-delete-action', 'remove-delete-action',
119
120
  'add-field-group', 'remove-field-group', 'add-field-to-field-group',
120
121
  'add-field-modification',
121
122
  'add-data-source', 'add-control',
@@ -131,6 +132,7 @@ Model from .mcp.json; prefix auto-applied from EXTENSION_PREFIX. Classes: member
131
132
  'add-display-method: display method with [SysClientCacheDataMethodAttribute].\n' +
132
133
  'add-table-method: canonical find/exist/findByRecId/validateWrite/validateDelete/initValue boilerplate.\n' +
133
134
  'add-field-modification: override base-table field label/mandatory in a table-extension.\n' +
135
+ 'add-delete-action: table DeleteActions entry — deleteActionName + optional deleteActionTable/deleteActionType (None|Restricted|Cascade|CascadeRestricted).\n' +
134
136
  'modify-property: any object-level property (TableGroup, TitleField1, TableType, Extends…) — see propertyPath.'
135
137
  },
136
138
  params: {
@@ -81,6 +81,10 @@ export declare const generateObjectTool: {
81
81
  type: string;
82
82
  description: string;
83
83
  };
84
+ preview: {
85
+ type: string;
86
+ description: string;
87
+ };
84
88
  dataSource: {
85
89
  type: string;
86
90
  description: string;
@@ -74,6 +74,7 @@ export const generateObjectTool = {
74
74
  description: '[scaffold:table] Storage type: Regular (default, omit), TempDB, InMemory. ⛔ NEVER pass as tableGroup.',
75
75
  },
76
76
  generateCommonFields: { type: 'boolean', description: '[scaffold:table] Auto-generate common fields based on table group patterns.' },
77
+ preview: { type: 'boolean', description: '[scaffold:table] Return the XML without writing to disk.' },
77
78
  dataSource: { type: 'string', description: '[scaffold:form] Optional: Table name for primary datasource.' },
78
79
  formPattern: {
79
80
  type: 'string',
@@ -92,7 +93,7 @@ export const generateObjectTool = {
92
93
  generateControls: { type: 'boolean', description: '[scaffold:form] Auto-generate grid controls for datasource.' },
93
94
  fields: {
94
95
  type: 'array',
95
- description: '[scaffold:report | fields] Structured field specs. Takes priority over fieldsHint. For mode="fields": name + optional edt/enumType/type/label/mandatory (EDT auto-resolved when omitted).',
96
+ description: '[scaffold:table|report | fields] Structured field specs; takes priority over fieldsHint. PREFER for enum-backed fields or an explicit EDT — a bare name cannot express either. name + optional edt/enumType/type/label/mandatory (EDT auto-resolved when omitted).',
96
97
  items: {
97
98
  type: 'object',
98
99
  properties: {
@@ -208,6 +208,10 @@ export declare const toolSchemas: ({
208
208
  type: string;
209
209
  description: string;
210
210
  };
211
+ preview: {
212
+ type: string;
213
+ description: string;
214
+ };
211
215
  dataSource: {
212
216
  type: string;
213
217
  description: string;
@@ -42,6 +42,19 @@ export const D365FO_FILE_PARAM_SPECS = {
42
42
  type: 'string',
43
43
  description: 'Replacement for the first occurrence of oldCode; pass "" to delete the snippet.',
44
44
  },
45
+ // table delete actions
46
+ deleteActionName: {
47
+ type: 'string',
48
+ description: 'Delete action name — conventionally the related table name.',
49
+ },
50
+ deleteActionTable: {
51
+ type: 'string',
52
+ description: 'Related table the delete action applies to (defaults to deleteActionName).',
53
+ },
54
+ deleteActionType: {
55
+ type: 'string (None | Restricted | Cascade | CascadeRestricted)',
56
+ description: 'Delete action to take on the related table. Defaults to Restricted.',
57
+ },
45
58
  // table fields
46
59
  fieldName: { type: 'string', description: 'Field name.' },
47
60
  fieldNewName: {
@@ -269,6 +282,12 @@ export const D365FO_FILE_OP_SPECS = {
269
282
  optional: ['relationConstraints', 'relationCardinality', 'relatedTableCardinality', 'relationshipType'],
270
283
  },
271
284
  'remove-relation': { required: ['relationName'], optional: [] },
285
+ 'add-delete-action': {
286
+ required: ['deleteActionName'],
287
+ optional: ['deleteActionTable', 'deleteActionType'],
288
+ note: 'objectType="table" only. deleteActionTable defaults to deleteActionName; deleteActionType defaults to Restricted.',
289
+ },
290
+ 'remove-delete-action': { required: ['deleteActionName'], optional: [] },
272
291
  'add-field-group': {
273
292
  required: ['fieldGroupName'],
274
293
  optional: ['fieldGroupFields', 'fieldGroupLabel'],
@@ -16,19 +16,7 @@
16
16
  import type { CallToolRequest } from '@modelcontextprotocol/sdk/types.js';
17
17
  import type { XppServerContext } from '../types/context.js';
18
18
  export declare function generateObjectTool(request: CallToolRequest, context: XppServerContext): Promise<{
19
- content: {
20
- type: string;
21
- text: string;
22
- }[];
23
- isError: boolean;
24
- } | {
25
- isError?: undefined;
26
19
  content: any;
27
- } | {
28
- content: {
29
- type: 'text';
30
- text: string;
31
- }[];
32
20
  isError?: boolean | undefined;
33
21
  }>;
34
22
  //# sourceMappingURL=generateObject.d.ts.map
@@ -19,14 +19,8 @@ export type GenerateSmartType = (typeof GENERATE_SMART_TYPES)[number];
19
19
  type SmartHandler = (args: any, context: XppServerContext) => Promise<any>;
20
20
  export declare const GENERATE_SMART_DISPATCH: Record<GenerateSmartType, SmartHandler>;
21
21
  export declare function generateSmartTool(request: CallToolRequest, context: XppServerContext): Promise<{
22
- content: {
23
- type: string;
24
- text: string;
25
- }[];
26
- isError: boolean;
27
- } | {
28
- isError?: undefined;
29
22
  content: any;
23
+ isError?: boolean | undefined;
30
24
  }>;
31
25
  export {};
32
26
  //# sourceMappingURL=generateSmart.d.ts.map
@@ -45,7 +45,11 @@ export async function generateSmartTool(request, context) {
45
45
  };
46
46
  }
47
47
  const result = await handler(rest, context);
48
- return { content: result?.content ?? [{ type: 'text', text: 'No results returned' }] };
48
+ // Preserve isError dropping it reported a rejected generation as a success.
49
+ return {
50
+ content: result?.content ?? [{ type: 'text', text: 'No results returned' }],
51
+ ...(result?.isError ? { isError: true } : {}),
52
+ };
49
53
  }
50
54
  // Tool registration (name, description, inputSchema) lives inline in
51
55
  // src/server/mcpServer.ts — the single source of truth for tool instructions.
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import { Tool } from '@modelcontextprotocol/sdk/types.js';
6
6
  import { XppSymbolIndex } from '../metadata/symbolIndex.js';
7
+ import { TableFieldSpec } from '../utils/smartXmlBuilder.js';
7
8
  import type { BridgeClient } from '../bridge/bridgeClient.js';
8
9
  interface GenerateSmartTableArgs {
9
10
  name: string;
@@ -20,8 +21,15 @@ interface GenerateSmartTableArgs {
20
21
  tableType?: string;
21
22
  copyFrom?: string;
22
23
  fieldsHint?: string;
24
+ /**
25
+ * Structured field specs. Takes priority over `fieldsHint`, which can only carry
26
+ * names and therefore cannot express enum-backed fields or an explicit EDT.
27
+ */
28
+ fields?: TableFieldSpec[];
23
29
  primaryKeyFields?: string[];
24
30
  generateCommonFields?: boolean;
31
+ /** Return the XML without touching disk. The default Windows path writes the file (eval #21). */
32
+ preview?: boolean;
25
33
  modelName?: string;
26
34
  projectPath?: string;
27
35
  solutionPath?: string;
@@ -118,7 +118,7 @@ export const generateSmartTableTool = {
118
118
  },
119
119
  };
120
120
  export async function handleGenerateSmartTable(args, symbolIndex, bridge) {
121
- const { name, label, tableGroup = 'Main', tableType, copyFrom, fieldsHint, primaryKeyFields, generateCommonFields, modelName, projectPath, solutionPath, packagePath: argPackagePath, methods: requestedMethods, } = args;
121
+ const { name, label, tableGroup = 'Main', tableType, copyFrom, fieldsHint, fields: fieldSpecs, primaryKeyFields, generateCommonFields, preview, modelName, projectPath, solutionPath, packagePath: argPackagePath, methods: requestedMethods, } = args;
122
122
  // Guard: 'TempDB' and 'InMemory' are NOT valid TableGroup values.
123
123
  if (tableGroup === 'TempDB' || tableGroup === 'InMemory') {
124
124
  return {
@@ -200,9 +200,10 @@ export async function handleGenerateSmartTable(args, symbolIndex, bridge) {
200
200
  throw new Error(`Failed to copy structure from ${copyFrom}: ${error instanceof Error ? error.message : String(error)}`);
201
201
  }
202
202
  }
203
+ const explicitFields = Array.isArray(fieldSpecs) ? fieldSpecs.filter(f => f?.name) : [];
203
204
  // Strategy 2: Generate common fields based on table group patterns.
204
- // Skipped when explicit fieldsHint is given (the caller stated the fields).
205
- if (generateCommonFields && !copyFrom && !fieldsHint) {
205
+ // Skipped when the caller stated the fields (fields[] or fieldsHint).
206
+ if (generateCommonFields && !copyFrom && !fieldsHint && explicitFields.length === 0) {
206
207
  console.log(`[generateSmartTable] Analyzing patterns for table group: ${tableGroup}`);
207
208
  try {
208
209
  const db = symbolIndex.getReadDb();
@@ -258,15 +259,16 @@ export async function handleGenerateSmartTable(args, symbolIndex, bridge) {
258
259
  // Continue without pattern-based fields
259
260
  }
260
261
  }
261
- // Strategy 3: Parse field hints and suggest EDTs
262
- if (fieldsHint && !copyFrom) {
263
- console.log(`[generateSmartTable] Parsing field hints: ${fieldsHint}`);
264
- const hintFields = fieldsHint.split(',').map(s => s.trim()).filter(s => s.length > 0);
265
- // Guard: reject reserved system field names before any generation attempt.
266
- // Adding a field named e.g. "CreatedDateTime" produces a compiler error:
267
- // "Invalid field name; 'CreatedDateTime' is reserved for system fields."
268
- // The platform auto-provides these fields; users should NOT declare them.
269
- const reservedHits = hintFields.filter(f => RESERVED_SYSTEM_FIELD_NAMES.has(f.toLowerCase()));
262
+ // Guard: reject reserved system field names before any generation attempt.
263
+ // Adding a field named e.g. "CreatedDateTime" produces a compiler error:
264
+ // "Invalid field name; 'CreatedDateTime' is reserved for system fields."
265
+ // The platform auto-provides these fields; users should NOT declare them.
266
+ {
267
+ const declaredNames = explicitFields.length > 0
268
+ ? explicitFields.map(f => f.name)
269
+ : (fieldsHint && !copyFrom ? fieldsHint.split(',').map(s => s.trim()).filter(s => s.length > 0) : []);
270
+ const sourceParam = explicitFields.length > 0 ? 'fields' : 'fieldsHint';
271
+ const reservedHits = declaredNames.filter(f => RESERVED_SYSTEM_FIELD_NAMES.has(f.toLowerCase()));
270
272
  if (reservedHits.length > 0) {
271
273
  return {
272
274
  content: [{
@@ -295,12 +297,31 @@ export async function handleGenerateSmartTable(args, symbolIndex, bridge) {
295
297
  return ` • "${f}" → ${suggestions[f.toLowerCase()] ?? '"CustomFieldName"'}`;
296
298
  }).join('\n')}`,
297
299
  ``,
298
- `🔄 **Call generate again** with the corrected \`fieldsHint\`.`,
300
+ `🔄 **Call generate again** with the corrected \`${sourceParam}\`.`,
299
301
  ].join('\n'),
300
302
  }],
301
303
  isError: true,
302
304
  };
303
305
  }
306
+ }
307
+ // Strategy 3a: structured field specs. Preferred over fieldsHint — an enum-backed
308
+ // field or an explicit EDT cannot be expressed by a bare name (eval #21).
309
+ if (explicitFields.length > 0 && !copyFrom) {
310
+ const specDb = symbolIndex.getReadDb();
311
+ for (const spec of explicitFields) {
312
+ // enumType and an explicit type are authoritative; only resolve an EDT when
313
+ // neither was given and the caller did not name one.
314
+ const edt = spec.enumType || spec.type
315
+ ? spec.edt
316
+ : (spec.edt ?? resolveBestEdt(spec.name, specDb));
317
+ fields.push({ ...spec, edt });
318
+ }
319
+ console.log(`[generateSmartTable] Added ${explicitFields.length} fields from fields[]`);
320
+ }
321
+ // Strategy 3b: Parse field hints and suggest EDTs
322
+ if (fieldsHint && !copyFrom && explicitFields.length === 0) {
323
+ console.log(`[generateSmartTable] Parsing field hints: ${fieldsHint}`);
324
+ const hintFields = fieldsHint.split(',').map(s => s.trim()).filter(s => s.length > 0);
304
325
  const hintDb = symbolIndex.getReadDb();
305
326
  for (const hint of hintFields) {
306
327
  // On duplicate hint names, suffix the second occurrence instead of dropping it.
@@ -455,7 +476,9 @@ export async function handleGenerateSmartTable(args, symbolIndex, bridge) {
455
476
  const resolvedPackagePath = argPackagePath || customPackagesRoot || configManager.getPackagePath();
456
477
  // getPackagePath() already probes C:\ and K:\ well-known locations before returning null,
457
478
  // so reaching here with null means neither location exists on this machine.
458
- if (!resolvedPackagePath && process.platform === 'win32') {
479
+ // preview never writes, so it must not require a write location — demanding one
480
+ // made "just show me the XML" fail on any machine without a D365FO install.
481
+ if (!resolvedPackagePath && process.platform === 'win32' && !preview) {
459
482
  throw new Error('\u274c Cannot determine PackagesLocalDirectory path.\n\n' +
460
483
  'Neither C:\\AosService\\PackagesLocalDirectory nor K:\\AosService\\PackagesLocalDirectory were found.\n\n' +
461
484
  'If your D365FO installation is on a different drive, add one of the following to your .mcp.json:\n' +
@@ -635,9 +658,9 @@ export async function handleGenerateSmartTable(args, symbolIndex, bridge) {
635
658
  isError: true,
636
659
  };
637
660
  }
638
- // On non-Windows (Azure/Linux): generate XML via SmartXmlBuilder and return as text
639
- // The bridge is not available on non-Windows file writing is handled by the local companion.
640
- if (isNonWindows) {
661
+ // Text-only path: non-Windows (bridge unavailable, the local companion writes the file)
662
+ // or an explicit preview=true, which is the only way to scaffold without a disk write.
663
+ if (isNonWindows || preview) {
641
664
  const xml = builder.buildTableXml({
642
665
  name: finalName,
643
666
  label: label || finalName,
@@ -679,7 +702,9 @@ export async function handleGenerateSmartTable(args, symbolIndex, bridge) {
679
702
  edtWarningBlock,
680
703
  noModelNote,
681
704
  ``,
682
- `ℹ️ MCP server is running on Azure/Linux — file writing is handled by the local Windows companion. This is the expected hybrid workflow.`,
705
+ preview && !isNonWindows
706
+ ? `ℹ️ preview=true — nothing was written to disk.`
707
+ : `ℹ️ MCP server is running on Azure/Linux — file writing is handled by the local Windows companion. This is the expected hybrid workflow.`,
683
708
  nextStep,
684
709
  ``,
685
710
  `\`\`\`xml`,
@@ -75,6 +75,8 @@ export declare function extractMethodNameFromSource(source: string | undefined):
75
75
  * Anything unrecognised returns undefined so the op's own default applies.
76
76
  */
77
77
  export declare function coerceNoYesFlag(value: unknown): boolean | undefined;
78
+ /** DeleteAction values accepted by the AxTable serialiser. */
79
+ export declare const DELETE_ACTION_TYPES: readonly ['None', 'Restricted', 'Cascade', 'CascadeRestricted'];
78
80
  /**
79
81
  * Writes the relation properties the bridge drops into an <AxTableRelation>.
80
82
  *
@@ -19,6 +19,7 @@ import { ProjectFileManager, ProjectFileFinder } from './createD365File.js';
19
19
  import { heuristicEdtBaseType } from './generateSmartTable.js';
20
20
  import { normalizeD365Xml } from '../utils/d365XmlNormalizer.js';
21
21
  import { upsertAxTableProperty, AX_TABLE_NON_EXISTENT_PROPERTIES, } from '../utils/axTablePropertyOrder.js';
22
+ import { upsertAxFormDesignProperty } from '../utils/axFormDesignProperties.js';
22
23
  import { enforceGrounding } from '../utils/provenanceStore.js';
23
24
  import { gateOnReferenceErrors } from './resolveReferences.js';
24
25
  import { checkAddControlAgainstParentPattern, isFormPatternEnforceEnabled, } from './validateFormPattern.js';
@@ -303,6 +304,17 @@ async function directXmlModifyProperty(filePath, propertyPath, propertyValue) {
303
304
  const escapedValue = String(propertyValue)
304
305
  .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
305
306
  const tagName = propertyPath.split(/[./]/).pop();
307
+ // Forms first: the bridge refuses modify-property for AxForm entirely, and the
308
+ // generic path below cannot serve Design properties either — Caption/Style also
309
+ // occur on controls, so it sees several matches and refuses (#37).
310
+ const formPatched = upsertAxFormDesignProperty(content, tagName, escapedValue);
311
+ if (formPatched) {
312
+ await fs.writeFile(filePath, normalizeD365Xml(formPatched), 'utf-8');
313
+ return {
314
+ success: true,
315
+ message: `✅ Form Design property '${tagName}'='${propertyValue}' set via direct XML (the bridge does not support modify-property for forms). File: ${filePath}`,
316
+ };
317
+ }
306
318
  const openTagRe = new RegExp(`<${tagName}\\b[^>]*>[\\s\\S]*?</${tagName}>`, 'g');
307
319
  const selfClosingRe = new RegExp(`<${tagName}\\b([^>]*)/>`, 'g');
308
320
  const openMatches = content.match(openTagRe) ?? [];
@@ -608,6 +620,71 @@ async function directXmlAddIndex(filePath, indexName, fields, allowDuplicates, a
608
620
  return null;
609
621
  }
610
622
  }
623
+ /** DeleteAction values accepted by the AxTable serialiser. */
624
+ export const DELETE_ACTION_TYPES = ['None', 'Restricted', 'Cascade', 'CascadeRestricted'];
625
+ /**
626
+ * add-delete-action / remove-delete-action on a TABLE, written straight to the XML.
627
+ *
628
+ * There is no bridge operation for DeleteActions at all (finding #36), so a
629
+ * cascading delete action was inexpressible through the modify surface — the only
630
+ * route was the forbidden whole-file overwrite. <DeleteActions> is a collection
631
+ * sibling, not part of the order-sensitive top-level property block, so patching
632
+ * it in place is safe. Shape matches MetadataWriteService.cs: Name, Table,
633
+ * DeleteAction.
634
+ */
635
+ async function directXmlDeleteAction(filePath, mode, name, table, deleteAction) {
636
+ try {
637
+ const rawContent = await fs.readFile(filePath, 'utf-8');
638
+ const content = rawContent.replace(/^/, '').replace(/\r\n/g, '\n');
639
+ // Only tables carry <DeleteActions> — bail on any other shape so a mis-typed
640
+ // objectType never corrupts a non-table file.
641
+ if (!/<AxTable\b/.test(content))
642
+ return null;
643
+ const blockRe = new RegExp(`[\\t ]*<AxTableDeleteAction>\\s*<Name>${escapeRegExp(name)}</Name>[\\s\\S]*?</AxTableDeleteAction>\\n?`);
644
+ const existing = blockRe.exec(content);
645
+ if (mode === 'remove') {
646
+ if (!existing) {
647
+ return { success: true, message: `✅ Delete action '${name}' not present in ${filePath} — nothing to remove.` };
648
+ }
649
+ const updated = content.replace(blockRe, '');
650
+ await fs.writeFile(filePath, normalizeD365Xml(updated), 'utf-8');
651
+ return { success: true, message: `✅ Delete action '${name}' removed. File: ${filePath}` };
652
+ }
653
+ if (existing) {
654
+ return { success: true, message: `✅ Delete action '${name}' already present in ${filePath} — skipped (idempotent).` };
655
+ }
656
+ const newElement = `\t\t<AxTableDeleteAction>\n` +
657
+ `\t\t\t<Name>${name}</Name>\n` +
658
+ `\t\t\t<Table>${table ?? name}</Table>\n` +
659
+ `\t\t\t<DeleteAction>${deleteAction ?? 'Restricted'}</DeleteAction>\n` +
660
+ `\t\t</AxTableDeleteAction>`;
661
+ let updated;
662
+ if (content.includes('<DeleteActions />')) {
663
+ updated = content.replace('<DeleteActions />', `<DeleteActions>\n${newElement}\n\t</DeleteActions>`);
664
+ }
665
+ else if (content.includes('</DeleteActions>')) {
666
+ updated = content.replace('</DeleteActions>', `${newElement}\n\t</DeleteActions>`);
667
+ }
668
+ else {
669
+ // No <DeleteActions> collection at all — not a shape we can safely patch.
670
+ return null;
671
+ }
672
+ if (updated === content)
673
+ return null;
674
+ await fs.writeFile(filePath, normalizeD365Xml(updated), 'utf-8');
675
+ return {
676
+ success: true,
677
+ message: `✅ Delete action '${name}' (${deleteAction ?? 'Restricted'} on ${table ?? name}) added. File: ${filePath}`,
678
+ };
679
+ }
680
+ catch (err) {
681
+ console.error(`[modify_d365fo_file] directXmlDeleteAction failed: ${err}`);
682
+ return null;
683
+ }
684
+ }
685
+ function escapeRegExp(s) {
686
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
687
+ }
611
688
  /**
612
689
  * Writes the relation properties the bridge drops into an <AxTableRelation>.
613
690
  *
@@ -749,6 +826,7 @@ const ModifyD365FileArgsSchema = z.object({
749
826
  'add-field', 'modify-field', 'rename-field', 'replace-all-fields', 'remove-field',
750
827
  'add-index', 'remove-index',
751
828
  'add-relation', 'remove-relation',
829
+ 'add-delete-action', 'remove-delete-action',
752
830
  'add-field-group', 'remove-field-group', 'add-field-to-field-group',
753
831
  'add-field-modification',
754
832
  'add-data-source',
@@ -880,6 +958,10 @@ const ModifyD365FileArgsSchema = z.object({
880
958
  relationCardinality: z.string().optional().describe('Cardinality on local side: ZeroMore | ZeroOne | ExactlyOne (default: ZeroMore).'),
881
959
  relatedTableCardinality: z.string().optional().describe('Cardinality on related side: ZeroMore | ZeroOne | ExactlyOne (default: ExactlyOne).'),
882
960
  relationshipType: z.string().optional().describe('Relationship type: Association | Composition | Aggregation | Link | Specialization (default: Association).'),
961
+ // For add-delete-action / remove-delete-action (table)
962
+ deleteActionTable: z.string().optional().describe('Related table the delete action applies to (e.g. "SalesLine"). Defaults to deleteActionName.'),
963
+ deleteActionName: z.string().optional().describe('Delete action name — conventionally the related table name. Required for both add- and remove-delete-action.'),
964
+ deleteActionType: z.enum(DELETE_ACTION_TYPES).optional().describe('None | Restricted (default) | Cascade | CascadeRestricted.'),
883
965
  // For add-field-group / remove-field-group / add-field-to-field-group (table, table-extension)
884
966
  fieldGroupName: z.string().optional().describe('Field group name. For add-field-to-field-group in a table-extension: name of the group (new or existing base-table group).'),
885
967
  fieldGroupFields: z.array(z.string()).optional().describe('Initial field names for add-field-group. Can be empty — add fields later with add-field-to-field-group.'),
@@ -1464,6 +1546,15 @@ export async function modifyD365FileTool(request, context) {
1464
1546
  }
1465
1547
  break;
1466
1548
  }
1549
+ case 'add-delete-action':
1550
+ case 'remove-delete-action': {
1551
+ // No bridge op exists for DeleteActions (#36) — this is the only path.
1552
+ const daName = args.deleteActionName ?? args.deleteActionTable;
1553
+ if (daName) {
1554
+ bridgeResult = await directXmlDeleteAction(actualFilePath, operation === 'add-delete-action' ? 'add' : 'remove', daName, args.deleteActionTable, args.deleteActionType);
1555
+ }
1556
+ break;
1557
+ }
1467
1558
  case 'add-field-group': {
1468
1559
  if (args.fieldGroupName) {
1469
1560
  bridgeResult = await bridgeAddFieldGroup(context.bridge, objectName, args.fieldGroupName, args.fieldGroupLabel, args.fieldGroupFields);
@@ -443,8 +443,9 @@ function checkGenericDocComment(code) {
443
443
  }
444
444
  /**
445
445
  * XML001 — AxTable XML missing an index with <AlternateKey>Yes</AlternateKey>.
446
- * Every D365FO table must have at least one index marked as alternate key
447
- * for the BPCheckAlternateKeyAbsent rule.
446
+ * Warning, not error: xppbp raises BPCheckAlternateKeyAbsent as a warning and the
447
+ * table still builds. As an error it made a legitimately single-index table
448
+ * unsatisfiable (eval #7).
448
449
  */
449
450
  function checkMissingAlternateKey(code) {
450
451
  const violations = [];
@@ -458,12 +459,11 @@ function checkMissingAlternateKey(code) {
458
459
  if (!/<AlternateKey>\s*Yes\s*<\/AlternateKey>/i.test(code)) {
459
460
  violations.push({
460
461
  rule: 'XML001',
461
- severity: 'error',
462
+ severity: 'warning',
462
463
  excerpt: '<AxTable> — no index with <AlternateKey>Yes</AlternateKey>',
463
- fix: 'Add at least one <AxTableIndex> with <AlternateKey>Yes</AlternateKey>. ' +
464
- 'D365FO requires every table to have an alternate key index ' +
465
- '(BPCheckAlternateKeyAbsent). ' +
466
- 'generate_smart adds this automatically via buildPrimaryKeyIndex.',
464
+ fix: 'Add an <AxTableIndex> with <AlternateKey>Yes</AlternateKey> unless the table ' +
465
+ 'deliberately has none xppbp reports BPCheckAlternateKeyAbsent as a warning and ' +
466
+ 'the table still builds. generate_smart adds one via buildPrimaryKeyIndex.',
467
467
  });
468
468
  }
469
469
  return violations;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * AxForm <Design> property upsert.
3
+ *
4
+ * The C# bridge rejects modify-property for forms outright ("modify-property not
5
+ * supported for objectType form via bridge"), so the Design annotations
6
+ * object_patterns(action="spec") prescribes — Pattern / PatternVersion / Style —
7
+ * could not be set through any grounded path (findings #37, corpus
8
+ * 2026-07-22T04__L2-form-over-view). The generic directXmlModifyProperty cannot
9
+ * serve them either: Caption/Style also occur on controls, so it sees several
10
+ * matches and refuses to guess.
11
+ *
12
+ * Ground truth for shape and order: eval/goldens/L1-form-listpage (VM-captured,
13
+ * built clean) — Design's direct-child properties carry `xmlns=""`, are
14
+ * alphabetical, and <Controls> terminates the block.
15
+ */
16
+ /** Direct-child properties of <Design> the serialiser writes. */
17
+ export declare const AX_FORM_DESIGN_PROPERTIES: Set<string>;
18
+ /**
19
+ * Set a form Design property, inserting it in alphabetical order when absent.
20
+ * Returns the updated XML, or null when the document is not an AxForm, has no
21
+ * <Design>, or the property is not a Design property (so the caller can surface
22
+ * the original error rather than write something invented).
23
+ */
24
+ export declare function upsertAxFormDesignProperty(xml: string, property: string, value: string): string | null;
25
+ //# sourceMappingURL=axFormDesignProperties.d.ts.map
@@ -0,0 +1,78 @@
1
+ /**
2
+ * AxForm <Design> property upsert.
3
+ *
4
+ * The C# bridge rejects modify-property for forms outright ("modify-property not
5
+ * supported for objectType form via bridge"), so the Design annotations
6
+ * object_patterns(action="spec") prescribes — Pattern / PatternVersion / Style —
7
+ * could not be set through any grounded path (findings #37, corpus
8
+ * 2026-07-22T04__L2-form-over-view). The generic directXmlModifyProperty cannot
9
+ * serve them either: Caption/Style also occur on controls, so it sees several
10
+ * matches and refuses to guess.
11
+ *
12
+ * Ground truth for shape and order: eval/goldens/L1-form-listpage (VM-captured,
13
+ * built clean) — Design's direct-child properties carry `xmlns=""`, are
14
+ * alphabetical, and <Controls> terminates the block.
15
+ */
16
+ /** Direct-child properties of <Design> the serialiser writes. */
17
+ export const AX_FORM_DESIGN_PROPERTIES = new Set([
18
+ 'Caption',
19
+ 'ColumnsMode',
20
+ 'DataSource',
21
+ 'Pattern',
22
+ 'PatternVersion',
23
+ 'ShowDeleteButton',
24
+ 'ShowNewButton',
25
+ 'Style',
26
+ 'TitleDataSource',
27
+ 'ViewEditMode',
28
+ 'WindowResize',
29
+ 'WindowType',
30
+ ]);
31
+ /**
32
+ * Set a form Design property, inserting it in alphabetical order when absent.
33
+ * Returns the updated XML, or null when the document is not an AxForm, has no
34
+ * <Design>, or the property is not a Design property (so the caller can surface
35
+ * the original error rather than write something invented).
36
+ */
37
+ export function upsertAxFormDesignProperty(xml, property, value) {
38
+ if (!/<AxForm[\s>]/.test(xml))
39
+ return null;
40
+ if (!AX_FORM_DESIGN_PROPERTIES.has(property))
41
+ return null;
42
+ const designStart = xml.search(/^[ \t]*<Design>/m);
43
+ if (designStart === -1)
44
+ return null;
45
+ const designOpen = xml.indexOf('>', designStart) + 1;
46
+ // Everything before <Controls> (or </Design> on a control-less form) is the
47
+ // direct-child property region — nested controls carry their own Caption/Style,
48
+ // and must never be touched.
49
+ const controlsRel = xml.slice(designOpen).search(/^[ \t]*<Controls[ />]/m);
50
+ const endRel = controlsRel !== -1 ? controlsRel : xml.slice(designOpen).search(/^[ \t]*<\/Design>/m);
51
+ if (endRel === -1)
52
+ return null;
53
+ const propsEnd = designOpen + endRel;
54
+ const head = xml.slice(0, designOpen);
55
+ const region = xml.slice(designOpen, propsEnd);
56
+ const tail = xml.slice(propsEnd);
57
+ const designIndent = /^([ \t]*)<Design>/m.exec(xml.slice(designStart))?.[1] ?? '\t';
58
+ const indent = `${designIndent}\t`;
59
+ const element = `${indent}<${property} xmlns="">${value}</${property}>\n`;
60
+ const existing = new RegExp(`^[ \\t]*<${property}\\b[^>]*?/>[ \\t]*\\n|^[ \\t]*<${property}\\b[^>]*>[\\s\\S]*?</${property}>[ \\t]*\\n`, 'm');
61
+ if (existing.test(region)) {
62
+ return head + region.replace(existing, element) + tail;
63
+ }
64
+ // Insert before the first existing property that sorts after this one.
65
+ const propLine = /^[ \t]*<([A-Za-z_][\w.-]*)\b/gm;
66
+ let insertAt = null;
67
+ for (let m = propLine.exec(region); m; m = propLine.exec(region)) {
68
+ if (AX_FORM_DESIGN_PROPERTIES.has(m[1]) && m[1].localeCompare(property) > 0) {
69
+ insertAt = m.index;
70
+ break;
71
+ }
72
+ }
73
+ const patched = insertAt === null
74
+ ? region.replace(/\s*$/, '\n') + element
75
+ : region.slice(0, insertAt) + element + region.slice(insertAt);
76
+ return head + patched + tail;
77
+ }
78
+ //# sourceMappingURL=axFormDesignProperties.js.map
@@ -10,6 +10,8 @@
10
10
  * caller picked on purpose)
11
11
  * 3. config/d365fo-mcp.json + config/secrets.json
12
12
  * 4. the ambient repo-root .env (pre-wizard installations keep working)
13
+ * 5. the built-in defaults for path settings, resolved against the
14
+ * installation directory (see defaultPathEnv)
13
15
  *
14
16
  * Multiple instances run from one source folder by pointing each at its own
15
17
  * config or .env file:
@@ -10,6 +10,8 @@
10
10
  * caller picked on purpose)
11
11
  * 3. config/d365fo-mcp.json + config/secrets.json
12
12
  * 4. the ambient repo-root .env (pre-wizard installations keep working)
13
+ * 5. the built-in defaults for path settings, resolved against the
14
+ * installation directory (see defaultPathEnv)
13
15
  *
14
16
  * Multiple instances run from one source folder by pointing each at its own
15
17
  * config or .env file:
@@ -25,7 +27,7 @@ import dotenv from 'dotenv';
25
27
  import { existsSync } from 'fs';
26
28
  import { dirname, isAbsolute, join, resolve } from 'path';
27
29
  import { fileURLToPath } from 'url';
28
- import { resolveConfigFiles, toEnvRecord } from '../config/configFile.js';
30
+ import { defaultPathEnv, resolveConfigFiles, toEnvRecord } from '../config/configFile.js';
29
31
  /** Env vars whose relative paths should resolve from the .env file directory. */
30
32
  const PATH_VARS = ['DB_PATH', 'LABELS_DB_PATH', 'METADATA_PATH'];
31
33
  /**
@@ -109,6 +111,14 @@ export function loadEnv(callerImportMetaUrl) {
109
111
  if (!fromRealEnv.has(key) && !pinnedByEnvFile.has(key))
110
112
  process.env[key] = value;
111
113
  }
114
+ // Lowest precedence of all: the defaults for the path settings nobody writes
115
+ // out, anchored to the installation directory rather than process.cwd(). See
116
+ // defaultPathEnv — without this the index of an npm install is built beside
117
+ // the package instead of in the directory chosen during setup.
118
+ for (const [key, value] of Object.entries(defaultPathEnv(files.baseDir))) {
119
+ if (!process.env[key])
120
+ process.env[key] = value;
121
+ }
112
122
  // Bridge the public D365FO_-prefixed setting name to the internal
113
123
  // DEV_ENVIRONMENT_TYPE that consumers read. Prefixed wins when both are set;
114
124
  // a lone plain entry is tolerated for backward compatibility.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "d365fo-mcp",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "MCP Server for X++ Code Completion in D365 Finance & Operations",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",