d365fo-mcp 1.8.3 → 1.8.5

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.
@@ -1,6 +1,6 @@
1
1
  // src/utils/loadEnv.ts
2
2
  import dotenv from "dotenv";
3
- import { existsSync as existsSync2 } from "fs";
3
+ import { existsSync as existsSync2, readFileSync as readFileSync2, statSync } from "fs";
4
4
  import { dirname as dirname2, isAbsolute as isAbsolute2, join as join2, resolve as resolve2 } from "path";
5
5
  import { fileURLToPath } from "url";
6
6
 
@@ -606,6 +606,12 @@ function defaultPathEnv(baseDir) {
606
606
 
607
607
  // src/utils/loadEnv.ts
608
608
  var PATH_VARS = ["DB_PATH", "LABELS_DB_PATH", "METADATA_PATH"];
609
+ var WRITE_POLICY_VARS = [
610
+ "D365FO_ALLOW_CROSS_MODEL_WRITE",
611
+ "D365FO_CROSS_MODEL_WRITE_MODELS"
612
+ ];
613
+ var writePolicySource = null;
614
+ var writePolicyStamp = "";
609
615
  function installRootFrom(callerDir) {
610
616
  let dir = resolve2(callerDir, "..");
611
617
  for (let up = 0; up < 3; up++) {
@@ -621,6 +627,15 @@ function loadEnv(callerImportMetaUrl) {
621
627
  const callerDir = dirname2(fileURLToPath(callerImportMetaUrl));
622
628
  const envPath = process.env.ENV_FILE ? resolve2(process.env.ENV_FILE) : resolve2(installRootFrom(callerDir), ".env");
623
629
  const fromRealEnv = new Set(Object.keys(process.env));
630
+ writePolicySource = {
631
+ envPath,
632
+ pinned: new Set(WRITE_POLICY_VARS.filter((k) => fromRealEnv.has(k)))
633
+ };
634
+ try {
635
+ writePolicyStamp = String(statSync(envPath).mtimeMs);
636
+ } catch {
637
+ writePolicyStamp = "-";
638
+ }
624
639
  const result = dotenv.config({ path: envPath, quiet: true });
625
640
  if (result.error && !process.env.ENV_FILE) {
626
641
  dotenv.config({ quiet: true });
@@ -1,6 +1,6 @@
1
1
  // src/utils/loadEnv.ts
2
2
  import dotenv from "dotenv";
3
- import { existsSync as existsSync2 } from "fs";
3
+ import { existsSync as existsSync2, readFileSync as readFileSync2, statSync } from "fs";
4
4
  import { dirname as dirname2, isAbsolute as isAbsolute2, join as join2, resolve as resolve2 } from "path";
5
5
  import { fileURLToPath } from "url";
6
6
 
@@ -606,6 +606,12 @@ function defaultPathEnv(baseDir) {
606
606
 
607
607
  // src/utils/loadEnv.ts
608
608
  var PATH_VARS = ["DB_PATH", "LABELS_DB_PATH", "METADATA_PATH"];
609
+ var WRITE_POLICY_VARS = [
610
+ "D365FO_ALLOW_CROSS_MODEL_WRITE",
611
+ "D365FO_CROSS_MODEL_WRITE_MODELS"
612
+ ];
613
+ var writePolicySource = null;
614
+ var writePolicyStamp = "";
609
615
  function installRootFrom(callerDir) {
610
616
  let dir = resolve2(callerDir, "..");
611
617
  for (let up = 0; up < 3; up++) {
@@ -621,6 +627,15 @@ function loadEnv(callerImportMetaUrl) {
621
627
  const callerDir = dirname2(fileURLToPath(callerImportMetaUrl));
622
628
  const envPath = process.env.ENV_FILE ? resolve2(process.env.ENV_FILE) : resolve2(installRootFrom(callerDir), ".env");
623
629
  const fromRealEnv = new Set(Object.keys(process.env));
630
+ writePolicySource = {
631
+ envPath,
632
+ pinned: new Set(WRITE_POLICY_VARS.filter((k) => fromRealEnv.has(k)))
633
+ };
634
+ try {
635
+ writePolicyStamp = String(statSync(envPath).mtimeMs);
636
+ } catch {
637
+ writePolicyStamp = "-";
638
+ }
624
639
  const result = dotenv.config({ path: envPath, quiet: true });
625
640
  if (result.error && !process.env.ENV_FILE) {
626
641
  dotenv.config({ quiet: true });
@@ -1,6 +1,6 @@
1
1
  // src/utils/loadEnv.ts
2
2
  import dotenv from "dotenv";
3
- import { existsSync as existsSync2 } from "fs";
3
+ import { existsSync as existsSync2, readFileSync as readFileSync2, statSync } from "fs";
4
4
  import { dirname as dirname2, isAbsolute as isAbsolute2, join as join2, resolve as resolve2 } from "path";
5
5
  import { fileURLToPath } from "url";
6
6
 
@@ -606,6 +606,12 @@ function defaultPathEnv(baseDir) {
606
606
 
607
607
  // src/utils/loadEnv.ts
608
608
  var PATH_VARS = ["DB_PATH", "LABELS_DB_PATH", "METADATA_PATH"];
609
+ var WRITE_POLICY_VARS = [
610
+ "D365FO_ALLOW_CROSS_MODEL_WRITE",
611
+ "D365FO_CROSS_MODEL_WRITE_MODELS"
612
+ ];
613
+ var writePolicySource = null;
614
+ var writePolicyStamp = "";
609
615
  function installRootFrom(callerDir) {
610
616
  let dir = resolve2(callerDir, "..");
611
617
  for (let up = 0; up < 3; up++) {
@@ -621,6 +627,15 @@ function loadEnv(callerImportMetaUrl) {
621
627
  const callerDir = dirname2(fileURLToPath(callerImportMetaUrl));
622
628
  const envPath = process.env.ENV_FILE ? resolve2(process.env.ENV_FILE) : resolve2(installRootFrom(callerDir), ".env");
623
629
  const fromRealEnv = new Set(Object.keys(process.env));
630
+ writePolicySource = {
631
+ envPath,
632
+ pinned: new Set(WRITE_POLICY_VARS.filter((k) => fromRealEnv.has(k)))
633
+ };
634
+ try {
635
+ writePolicyStamp = String(statSync(envPath).mtimeMs);
636
+ } catch {
637
+ writePolicyStamp = "-";
638
+ }
624
639
  const result = dotenv.config({ path: envPath, quiet: true });
625
640
  if (result.error && !process.env.ENV_FILE) {
626
641
  dotenv.config({ quiet: true });
@@ -5,13 +5,13 @@
5
5
  */
6
6
  export const getWorkspaceInfoTool = {
7
7
  name: 'get_workspace_info',
8
- description: `ALWAYS call FIRST at session start. Returns model name, package path, framework directory, project path, environment type, and EXTENSION_PREFIX. Flags placeholder model names and missing prefix. Use projectName/projectPath params for solution switching. This is the authoritative source for target model — not search results.`,
8
+ description: `ALWAYS call FIRST at session start. Returns model name, package path, framework directory, project path, environment type, and EXTENSION_PREFIX. Flags placeholder model names and missing prefix. projectName/projectPath ONLY when the USER changed project. This is the authoritative source for target model — not search results.`,
9
9
  inputSchema: {
10
10
  type: 'object',
11
11
  properties: {
12
12
  projectName: {
13
13
  type: 'string',
14
- description: 'Preferred way to switch projects. Just the model name, e.g. "ContosoEDS" or "ContosoBank". The server resolves the full path from D365FO_SOLUTIONS_PATH automatically. Use this when the user says "switch to <project>" or opens a different solution.',
14
+ description: 'Only when the USER says "switch to <project>". Just the model name, e.g. "ContosoEDS"; path resolved from D365FO_SOLUTIONS_PATH. NOT a way to reach another model reads span every model already, writes stay in the workspace model.',
15
15
  },
16
16
  projectPath: {
17
17
  type: 'string',
@@ -10,6 +10,7 @@ import { getConfigManager, fallbackPackagePath } from '../utils/configManager.js
10
10
  import { describePackagesRootScan } from '../utils/packagesRoot.js';
11
11
  import { registerCustomModel, resolveObjectPrefix, applyObjectPrefix, getObjectSuffix, applyObjectSuffix, getExtensionNamingStyle } from '../utils/modelClassifier.js';
12
12
  import { PackageResolver } from '../utils/packageResolver.js';
13
+ import { crossModelWriteRefusal } from '../utils/crossModelWriteGuard.js';
13
14
  import { ensureXppDocComment, ensureBlankLineBeforeClosingBrace } from '../utils/xppDocGen.js';
14
15
  import { reindentXppSource } from '../utils/xppFormat.js';
15
16
  import { decodeXmlEntitiesFromXppSource } from './modifyD365File.js';
@@ -3796,6 +3797,25 @@ export async function handleCreateD365File(request, context) {
3796
3797
  isError: true,
3797
3798
  };
3798
3799
  }
3800
+ // Cross-model guard: creating INTO a custom model other than the one this
3801
+ // workspace targets is the same mistake as modifying one — the object lands
3802
+ // outside this project's version control and inside code other models inherit.
3803
+ // `actualModelName` is what the write will actually use (caller's modelName, or
3804
+ // the workspace's), so the check sits after every fallback has been applied.
3805
+ const crossModelCreateRefusal = crossModelWriteRefusal({
3806
+ objectName: args.objectName,
3807
+ objectType: args.objectType,
3808
+ owningModel: actualModelName,
3809
+ activeModel: getConfigManager().getWriteAnchorModel() ?? '',
3810
+ toolSwitchedModel: getConfigManager().getToolProjectSwitch()?.forcedModel ?? null,
3811
+ action: 'create',
3812
+ });
3813
+ if (crossModelCreateRefusal) {
3814
+ return {
3815
+ content: [{ type: 'text', text: crossModelCreateRefusal }],
3816
+ isError: true,
3817
+ };
3818
+ }
3799
3819
  // Apply extension prefix to object name
3800
3820
  const objectPrefix = resolveObjectPrefix(actualModelName);
3801
3821
  const namingStyle = getExtensionNamingStyle();
@@ -17,6 +17,7 @@ import * as path from 'path';
17
17
  import { getConfigManager } from '../utils/configManager.js';
18
18
  import { defaultPackagesRoot } from '../utils/packagesRoot.js';
19
19
  import { PackageResolver } from '../utils/packageResolver.js';
20
+ import { crossModelWriteRefusal } from '../utils/crossModelWriteGuard.js';
20
21
  import { detectEol } from '../utils/eolUtils.js';
21
22
  import { isExtensionLabelFile } from '../metadata/labelParser.js';
22
23
  import { ProjectFileManager, ProjectFileFinder } from './createD365File.js';
@@ -445,6 +446,26 @@ export async function createLabelTool(request, context) {
445
446
  resolvedPackagePath = packagePath || configManager.getPackagePath() || defaultPackagesRoot();
446
447
  resolvedPackageName = model;
447
448
  }
449
+ // Cross-model guard: a label file belongs to exactly one model, so writing a
450
+ // label into another model's file has the same consequences as editing that
451
+ // model's objects — and labels are usually the first thing an agent adds when
452
+ // it drifts into the wrong model. `model` is the model directory the write
453
+ // resolves to, so this compares the real target, not the caller's intent.
454
+ const crossModelLabelRefusal = crossModelWriteRefusal({
455
+ objectName: `@${args.labelFileId}:${args.labelId}`,
456
+ objectType: 'label',
457
+ owningModel: model,
458
+ owningPackage: resolvedPackageName,
459
+ activeModel: configManager.getWriteAnchorModel() ?? '',
460
+ toolSwitchedModel: configManager.getToolProjectSwitch()?.forcedModel ?? null,
461
+ action: 'create',
462
+ });
463
+ if (crossModelLabelRefusal) {
464
+ return {
465
+ content: [{ type: 'text', text: crossModelLabelRefusal }],
466
+ isError: true,
467
+ };
468
+ }
448
469
  const modelDir = path.join(resolvedPackagePath, resolvedPackageName, model);
449
470
  const axLabelDir = path.join(modelDir, 'AxLabelFile');
450
471
  const labelResourcesDir = path.join(axLabelDir, 'LabelResources');
@@ -73,7 +73,11 @@ export const D365FO_FILE_PARAM_SPECS = {
73
73
  fieldMandatory: { type: 'boolean', description: 'Mark the field Mandatory=Yes.' },
74
74
  fieldLabel: { type: 'string', description: 'Field label.' },
75
75
  fieldHelpText: { type: 'string', description: 'Field help text.' },
76
- fieldEnumType: { type: 'string', description: 'Enum name to set on an enum-typed field.' },
76
+ fieldEnumType: {
77
+ type: 'string',
78
+ description: 'Enum name for an enum-typed field. On add-field this is all an enum field needs — ' +
79
+ 'it writes AxTableFieldEnum + EnumType, no EDT.',
80
+ },
77
81
  fieldStringSize: { type: 'string', description: 'String size to set on a string-typed field.' },
78
82
  dataField: {
79
83
  type: 'string',
@@ -267,9 +271,12 @@ export const D365FO_FILE_OP_SPECS = {
267
271
  },
268
272
  'add-field': {
269
273
  required: ['fieldName'],
270
- optional: ['fieldType', 'fieldBaseType', 'fieldMandatory', 'fieldLabel', 'dataField', 'dataSource', 'fieldGroupName'],
271
- mutationOneOf: ['fieldType', 'dataField'],
272
- note: 'Table/table-extension: fieldType (EDT) is REQUIRED. data-entity-extension: pass dataField AND ' +
274
+ optional: ['fieldType', 'fieldBaseType', 'fieldEnumType', 'fieldMandatory', 'fieldLabel', 'dataField', 'dataSource', 'fieldGroupName'],
275
+ mutationOneOf: ['fieldType', 'fieldEnumType', 'dataField'],
276
+ note: 'Enum field: pass fieldEnumType="<enum name>" and NO fieldType an enum-typed table field ' +
277
+ 'is an AxTableFieldEnum with an EnumType and needs no EDT. (fieldType is the EDT name here, ' +
278
+ 'never an XML element name like "AxTableFieldEnum".) ' +
279
+ 'Table/table-extension: otherwise fieldType (EDT) is REQUIRED. data-entity-extension: pass dataField AND ' +
273
280
  'dataSource instead — BOTH, or nothing is written; a mapped field has no EDT of its own, it points ' +
274
281
  'at dataField on the entity data source dataSource. fieldGroupName is optional and only applies to ' +
275
282
  'a data-entity-extension: it appends the field to that BASE-entity field group (shipped extensions ' +
@@ -21,6 +21,7 @@ import { methodStubsForPattern, injectMethodStubs } from '../knowledge/formPatte
21
21
  import { findBaseFormXml } from './modifyD365File.js';
22
22
  import { getFieldControlMap, getTableTitleField } from '../utils/fieldControlTypes.js';
23
23
  import { lookupSymbolNocase } from '../utils/symbolLookup.js';
24
+ import { scaffoldWriteRefusalResult } from './writeAnchorGuard.js';
24
25
  /**
25
26
  * Symbol types a form datasource may bind to. Views are indexed as 'view'
26
27
  * (see symbolIndex.indexViews) and are legal in <Table> just like tables.
@@ -560,6 +561,15 @@ export async function handleGenerateSmartForm(args, symbolIndex) {
560
561
  if (finalName !== name) {
561
562
  console.log(`[generateSmartForm] Applied naming: ${name} → ${finalName}`);
562
563
  }
564
+ // See generateSmartTable: the resolved model follows the ACTIVE project, the
565
+ // write anchor does not. Checked before the first byte is written.
566
+ const anchorRefusal = scaffoldWriteRefusalResult({
567
+ objectName: finalName,
568
+ objectType: 'form',
569
+ targetModel: resolvedModel,
570
+ });
571
+ if (anchorRefusal)
572
+ return anchorRefusal;
563
573
  // Generate XML: clone an existing form (preferred) or build from a template.
564
574
  // Without an explicit pattern, default to the majority pattern mined from
565
575
  // standard models (property_stats), falling back to SimpleList.
@@ -32,6 +32,7 @@ import { resolveObjectPrefix, applyObjectPrefix, getObjectSuffix, applyObjectSuf
32
32
  import { extractModelFromProject, findProjectInSolution } from '../utils/projectUtils.js';
33
33
  import { normalizeD365Xml } from '../utils/d365XmlNormalizer.js';
34
34
  import { canonicalSymbolName, lookupSymbolNocase } from '../utils/symbolLookup.js';
35
+ import { scaffoldWriteRefusalResult } from './writeAnchorGuard.js';
35
36
  export const generateSmartReportTool = {
36
37
  name: 'generate_smart_report',
37
38
  description: `🎨 AI-driven SSRS report generation — creates up to 7 D365FO objects in one call.
@@ -221,6 +222,16 @@ export async function handleGenerateSmartReport(args, symbolIndex, bridge) {
221
222
  finalName = applyObjectSuffix(finalName, objectSuffix);
222
223
  if (finalName !== name)
223
224
  log(`Applied naming: ${name} → ${finalName}`);
225
+ // See generateSmartTable: the resolved model follows the ACTIVE project, the
226
+ // write anchor does not. A report scaffold writes several objects at once, so
227
+ // this is checked before any of them exists.
228
+ const anchorRefusal = scaffoldWriteRefusalResult({
229
+ objectName: finalName,
230
+ objectType: 'report',
231
+ targetModel: resolvedModel,
232
+ });
233
+ if (anchorRefusal)
234
+ return anchorRefusal;
224
235
  // Derived object names
225
236
  const tmpTableName = `${finalName}Tmp`;
226
237
  const contractClassName = `${finalName}Contract`;
@@ -13,6 +13,7 @@ import { ProjectFileManager } from './createD365File.js';
13
13
  import { extractModelFromProject, findProjectInSolution } from '../utils/projectUtils.js';
14
14
  import { normalizeD365Xml } from '../utils/d365XmlNormalizer.js';
15
15
  import { lookupSymbolNocase } from '../utils/symbolLookup.js';
16
+ import { scaffoldWriteRefusalResult } from './writeAnchorGuard.js';
16
17
  export const generateSmartTableTool = {
17
18
  name: 'generate_smart_table',
18
19
  description: 'Generate AxTable XML with AI-driven field/index/relation suggestions based on indexed patterns. Can copy structure from existing tables, analyze table group patterns, or use field hints.',
@@ -559,6 +560,16 @@ export async function handleGenerateSmartTable(args, symbolIndex, bridge) {
559
560
  if (finalName !== name) {
560
561
  console.log(`[generateSmartTable] Applied naming: ${name} → ${finalName}`);
561
562
  }
563
+ // Where this scaffold would land, checked before anything is written. The
564
+ // model above comes from the ACTIVE project, which a get_workspace_info switch
565
+ // moves; writes stay anchored to the model the workspace resolved on its own.
566
+ const anchorRefusal = scaffoldWriteRefusalResult({
567
+ objectName: finalName,
568
+ objectType: 'table',
569
+ targetModel: resolvedModel,
570
+ });
571
+ if (anchorRefusal)
572
+ return anchorRefusal;
562
573
  // Generate standard methods (find, exist) based on primary key fields
563
574
  const generatedMethods = [];
564
575
  if (requestedMethods && requestedMethods.length > 0) {
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import { z } from 'zod';
7
7
  import { formatLabelReference } from '../utils/labelReference.js';
8
+ import { labelMissingOnDisk } from '../utils/labelDiskCheck.js';
8
9
  const GetLabelInfoArgsSchema = z.object({
9
10
  labelId: z
10
11
  .string()
@@ -96,6 +97,36 @@ export async function getLabelInfoTool(request, context) {
96
97
  // #33/#41: never emit `@SYS:@SYS67433` — an id that already carries its label
97
98
  // file id is a complete reference, and xppbp rejects the doubled form.
98
99
  const ref = formatLabelReference(first.labelFileId, labelId);
100
+ // An index row is not proof the label exists. Confirm against the .label.txt
101
+ // before handing back a reference the caller will paste into XML — a stale row
102
+ // otherwise surfaces as `Unknown label` at build time, several steps later.
103
+ //
104
+ // Every model, including Microsoft's: gating this on isCustomModel() looks
105
+ // right and is not, because isStandardModel() is defined as "not custom" and
106
+ // an unrecognised model therefore reads as Microsoft's. The shared core model
107
+ // whose phantom label started all this is exactly such a model, so the gate
108
+ // would have switched the check off in the one place it had to fire. What
109
+ // makes the big files affordable is the read budget in labelMissingOnDisk:
110
+ // the @SYS sweep that measured 17 s now gives up at 218 ms with no verdict.
111
+ const indexedPaths = symbolIndex
112
+ .getLabelFilePaths(first.labelFileId, first.model)
113
+ .map(p => p.filePath);
114
+ if (await labelMissingOnDisk(labelId, indexedPaths)) {
115
+ return {
116
+ content: [{
117
+ type: 'text',
118
+ text: `⚠️ Label "${ref}" is in the symbol index but NOT in its label file on disk — ` +
119
+ `treat it as NOT existing.\n` +
120
+ `Checked: ${indexedPaths.join(', ')}\n\n` +
121
+ `The index is ahead of the file system (a rolled-back run, a rebuild outside this ` +
122
+ `server, or a checkout). Using this reference compiles to a best-practice error ` +
123
+ `"Unknown label '${ref}'".\n\n` +
124
+ `Create it: labels(action="create", labelId="${labelId}", labelFileId="${first.labelFileId}", ` +
125
+ `model="${first.model}", translations=[{language:"en-US", text:"…"}])`,
126
+ }],
127
+ isError: true,
128
+ };
129
+ }
99
130
  const lines = [
100
131
  `Label: ${ref}`,
101
132
  `Model: ${first.model} | LabelFile: ${first.labelFileId}`,
@@ -167,7 +167,7 @@ export declare function modifyD365FileTool(request: CallToolRequest, context: Xp
167
167
  *
168
168
  * Idempotent: a name that already carries the prefix — in either the underscore
169
169
  * or the bare form, case-insensitively — is left untouched, so an agent that
170
- * prefixes by hand does not end up with HBR_HBR_Foo.
170
+ * prefixes by hand does not end up with DEMO_DEMO_Foo.
171
171
  */
172
172
  export declare function applyExtensionMemberPrefix(args: Record<string, any>, objectType: string, operation: string, modelName: string): string;
173
173
  /**
@@ -1126,7 +1126,7 @@ const ModifyD365FileArgsSchema = z.object({
1126
1126
  fieldMandatory: z.boolean().optional().describe('Is field mandatory'),
1127
1127
  fieldLabel: z.string().optional().describe('Field label'),
1128
1128
  fieldHelpText: z.string().optional().describe('Field help text (modify-field).'),
1129
- fieldEnumType: z.string().optional().describe('Enum name to set on the field (modify-field, for enum-typed fields).'),
1129
+ fieldEnumType: z.string().optional().describe('Enum name for an enum-typed field. On add-field this replaces fieldType entirely — it writes AxTableFieldEnum + EnumType and needs no EDT. Also settable later with modify-field.'),
1130
1130
  fieldStringSize: z.string().optional().describe('String size to set on the field (modify-field, for string-typed fields).'),
1131
1131
  fields: z.array(z.object({
1132
1132
  name: z.string(),
@@ -1457,14 +1457,17 @@ export async function modifyD365FileTool(request, context) {
1457
1457
  // Ownership comes from the path's <Model> segment, not the <Package> segment
1458
1458
  // used above: one package can carry several models.
1459
1459
  const owningModel = containment.modelSegment ?? null;
1460
- const activeModel = getConfigManager().getModelName() ?? '';
1460
+ // The write ANCHOR, not the active model: a get_workspace_info project switch
1461
+ // moves reads, and must not move what this guard measures writes against.
1462
+ const activeModel = getConfigManager().getWriteAnchorModel() ?? '';
1461
1463
  const crossModelRefusal = crossModelWriteRefusal({
1462
1464
  objectName,
1463
1465
  objectType,
1464
1466
  owningModel,
1465
1467
  owningPackage: containment.packageSegment ?? resolvedModelFromPath,
1466
1468
  activeModel,
1467
- explicitModelName: modelName,
1469
+ toolSwitchedModel: getConfigManager().getToolProjectSwitch()?.forcedModel ?? null,
1470
+ action: 'modify',
1468
1471
  existingExtensions: findExtensionsInModel(symbolIndex, baseObjectOf(objectName, objectType), activeModel),
1469
1472
  });
1470
1473
  if (crossModelRefusal) {
@@ -1682,7 +1685,31 @@ export async function modifyD365FileTool(request, context) {
1682
1685
  // (the mapped-field path above has no fieldType at all), so the type-specific half
1683
1686
  // of the contract is enforced here instead of silently falling through to a null
1684
1687
  // bridge result and a generic "required parameters may be missing".
1685
- if (args.fieldName && !args.fieldType) {
1688
+ const enumTypeArg = args.fieldEnumType?.trim() || undefined;
1689
+ // fieldType is an EDT NAME here. In `create` the sibling key fields[].fieldType is
1690
+ // the XML element name ("AxTableFieldEnum"), and that collision gets carried over
1691
+ // into add-field, where it used to be accepted and produce a bare AxTableFieldString
1692
+ // referencing a non-existent EDT — a wrong field, discovered only at build time.
1693
+ // Anchored on the metamodel's own container names, not on "starts with Ax
1694
+ // and contains Field": that broader shape also rejected any legitimate EDT
1695
+ // whose name happens to read that way, and refusing a valid EDT is the
1696
+ // same class of wrong answer this check exists to prevent.
1697
+ if (args.fieldType && /^Ax(Table|View|Query|Map|DataEntityView)[A-Za-z]*Field[A-Za-z0-9]*$/i.test(args.fieldType)) {
1698
+ return {
1699
+ content: [{
1700
+ type: 'text',
1701
+ text: `❌ fieldType="${args.fieldType}" is an XML element name, not an EDT — nothing was written.\n` +
1702
+ `On add-field, fieldType is the EDT NAME (e.g. "TransDate", "ItemId"); the XML element is ` +
1703
+ `chosen from fieldBaseType.\n` +
1704
+ `For an enum field pass fieldEnumType="<enum name>" instead — no EDT is needed:\n` +
1705
+ ` d365fo_file(action="modify", objectType="${objectType}", objectName="…", ` +
1706
+ `operation="add-field", fieldName="${args.fieldName ?? 'MyField'}", fieldEnumType="MyEnum")\n` +
1707
+ `\n${renderOpSpec('add-field')}`,
1708
+ }],
1709
+ isError: true,
1710
+ };
1711
+ }
1712
+ if (args.fieldName && !args.fieldType && !enumTypeArg) {
1686
1713
  const mappedOnly = args.dataField || args.dataSource;
1687
1714
  return {
1688
1715
  content: [{
@@ -1690,14 +1717,53 @@ export async function modifyD365FileTool(request, context) {
1690
1717
  text: (mappedOnly
1691
1718
  ? `❌ dataField/dataSource describe a data-entity mapped field and do not apply to ` +
1692
1719
  `objectType="${objectType}" — nothing was written.\n` +
1693
- `On a table or table-extension a field needs fieldType (its EDT).\n`
1694
- : `❌ add-field on objectType="${objectType}" requires fieldType (the EDT) — ` +
1695
- `nothing was written.\n`) +
1720
+ `On a table or table-extension a field needs fieldType (its EDT), or ` +
1721
+ `fieldEnumType for an enum field.\n`
1722
+ : `❌ add-field on objectType="${objectType}" requires fieldType (the EDT), or ` +
1723
+ `fieldEnumType for an enum field — nothing was written.\n`) +
1696
1724
  `\n${renderOpSpec('add-field')}`,
1697
1725
  }],
1698
1726
  isError: true,
1699
1727
  };
1700
1728
  }
1729
+ // Enum field: AxTableFieldEnum + <EnumType>, and NO EDT — an enum-typed table
1730
+ // field does not need one. Requiring an EDT here is what used to send callers off
1731
+ // building an AxEdtEnum wrapper, guessing at <Extends>, and failing the build twice
1732
+ // before getting there. fieldType stays accepted for the rarer "enum EDT" case.
1733
+ if (args.fieldName && enumTypeArg) {
1734
+ bridgeResult = await bridgeAddField(context.bridge, objectName, args.fieldName, 'Enum', args.fieldType, // usually undefined; an enum EDT when the caller has one
1735
+ args.fieldMandatory, args.fieldLabel);
1736
+ // EnumType is set in a second call on purpose: the bridge's AddField RPC has no
1737
+ // enumType parameter, while ModifyField does. Doing it here keeps this a
1738
+ // single tool call for the caller AND works with the bridge already deployed —
1739
+ // no rebuild, which is the part that silently keeps the old binary.
1740
+ if (bridgeResult?.success) {
1741
+ const enumSet = await bridgeModifyField(context.bridge, objectName, args.fieldName, { enumType: enumTypeArg });
1742
+ if (enumSet && !enumSet.success) {
1743
+ // Undo the half-written field. Two calls are not atomic, and what
1744
+ // they can leave behind — an AxTableFieldEnum with no enum — is a
1745
+ // field the caller did not ask for. Worse, the bridge's AddField
1746
+ // does not check for an existing field, so an agent that reads
1747
+ // "failed" and simply repeats the call ends up with the field
1748
+ // twice. Rolling back restores the pre-call state, which is the
1749
+ // only state a failed operation may leave.
1750
+ const undone = await bridgeRemoveField(context.bridge, objectName, args.fieldName);
1751
+ bridgeResult = {
1752
+ success: false,
1753
+ message: undone?.success
1754
+ ? `EnumType could not be set (${enumSet.message}) — the field was rolled back and ` +
1755
+ `nothing was written. Check that enum "${enumTypeArg}" exists ` +
1756
+ `(get_object_info objectType="enum"), then retry add-field.`
1757
+ : `Field '${args.fieldName}' was created but EnumType could not be set ` +
1758
+ `(${enumSet.message}), and rolling the field back failed too. The field is an ` +
1759
+ `AxTableFieldEnum with no enum — do NOT repeat add-field, it would add a SECOND ` +
1760
+ `field of the same name. Fix it with operation="modify-field", ` +
1761
+ `fieldEnumType="${enumTypeArg}", or remove it with operation="remove-field".`,
1762
+ };
1763
+ }
1764
+ }
1765
+ break;
1766
+ }
1701
1767
  if (args.fieldName && args.fieldType) {
1702
1768
  // fieldType is the EDT name; fieldBaseType is the primitive base type.
1703
1769
  // When fieldBaseType is omitted, auto-resolve it from the symbol index so the
@@ -2190,13 +2256,29 @@ export async function modifyD365FileTool(request, context) {
2190
2256
  // up front instead of letting it become a build error the agent hunts by hand.
2191
2257
  const xppLintWarnings = lintXppSelect(args.sourceCode ?? args.methodCode ?? args.newCode);
2192
2258
  const xppLintNote = xppLintWarnings.length > 0 ? `\n\n${xppLintWarnings.join('\n\n')}` : '';
2259
+ // Two BP rules fire on a field that compiles perfectly, so they are invisible
2260
+ // until a BP run several steps later — and one of them (the label copy) then
2261
+ // needs new labels, i.e. rework of what was just written. Say it here instead.
2262
+ let addFieldBpNote = '';
2263
+ if (operation === 'add-field' && (objectType === 'table' || objectType === 'table-extension')) {
2264
+ const notes = [
2265
+ `⚠️ BP: a table field must belong to a field group (BPErrorTableFieldNotInFieldGroup):\n` +
2266
+ ` d365fo_file(action="modify", objectType="${objectType}", objectName="${objectName}", ` +
2267
+ `operation="add-field-to-field-group", fieldName="${args.fieldName}", fieldGroupName="<group>")`,
2268
+ ];
2269
+ if (args.fieldEnumType) {
2270
+ notes.push(`⚠️ BP: the field's Label must be a DIFFERENT label id than the enum's own label ` +
2271
+ `(BPErrorFieldLabelIsCopyOfEnumLabel) — same visible text is fine, same id is not.`);
2272
+ }
2273
+ addFieldBpNote = `\n\n${notes.join('\n')}`;
2274
+ }
2193
2275
  return {
2194
2276
  content: [
2195
2277
  {
2196
2278
  type: 'text',
2197
2279
  text: `✅ ${operation} on ${objectType} "${objectName}" — applied via IMetadataProvider.Update()\n\n` +
2198
2280
  `**File:** ${actualFilePath}${addControlNote}${generationNote}${bridgeValidation}${projectMessage}\n` +
2199
- `🔧 API: ${bridgeResult.message}${xppLintNote}${backupNote}` +
2281
+ `🔧 API: ${bridgeResult.message}${xppLintNote}${addFieldBpNote}${backupNote}` +
2200
2282
  (ignoredParamsWarning ? `\n\n${ignoredParamsWarning}` : '') + `\n\n` +
2201
2283
  `**Next steps:**\n- Review changes in Visual Studio\n- Build the model to validate`,
2202
2284
  },
@@ -2279,7 +2361,7 @@ function findExtensionsInModel(symbolIndex, baseObject, model) {
2279
2361
  *
2280
2362
  * Idempotent: a name that already carries the prefix — in either the underscore
2281
2363
  * or the bare form, case-insensitively — is left untouched, so an agent that
2282
- * prefixes by hand does not end up with HBR_HBR_Foo.
2364
+ * prefixes by hand does not end up with DEMO_DEMO_Foo.
2283
2365
  */
2284
2366
  export function applyExtensionMemberPrefix(args, objectType, operation, modelName) {
2285
2367
  if (!EXTENSION_OBJECT_TYPES.has(objectType))
@@ -0,0 +1,38 @@
1
+ /**
2
+ * The "Prefix Configuration" section of `get_workspace_info`.
3
+ *
4
+ * Two rules decide everything here:
5
+ *
6
+ * 1. The prefix is reported for the model that WRITES land in — the write anchor
7
+ * (see ConfigManager.getWriteAnchorModel). A tool-initiated project switch
8
+ * moves the active project without moving that anchor, so after one the two
9
+ * are different models with different prefixes; reporting the active model's
10
+ * prefix would state a token no write would ever apply.
11
+ * 2. The reported value always carries its origin. The prefix can come from the
12
+ * model's own objects, from EXTENSION_PREFIX, or from the model name, and a
13
+ * bare "Effective prefix: ConFin" under "EXTENSION_PREFIX: Con" reads as
14
+ * approved rather than as the disagreement it is.
15
+ */
16
+ /**
17
+ * The model a write would actually land in.
18
+ *
19
+ * Normally the anchor: a project switch does not move writes. But when the
20
+ * operator has allowed writes into the switched-to model in configuration, the
21
+ * guard lets them through and they land in the ACTIVE model — so that is the
22
+ * model whose prefix a create would apply. Reporting the anchor's prefix there
23
+ * would be the same defect one state over.
24
+ */
25
+ export declare function modelWritesLandIn(anchorModel: string | null, activeModel: string | null): string | null;
26
+ export interface PrefixDiagnostics {
27
+ /** Lines for the "## Prefix Configuration" section, blank line included. */
28
+ lines: string[];
29
+ /** The prefix a write would apply — for the Extension Naming samples. */
30
+ effectivePrefix: string;
31
+ }
32
+ /**
33
+ * @param writeModel model writes are anchored to; the prefix is resolved for it
34
+ * @param readModel model reads currently come from — differs from `writeModel`
35
+ * only while a tool project switch is in effect
36
+ */
37
+ export declare function buildPrefixDiagnostics(writeModel: string | null, readModel: string | null): PrefixDiagnostics;
38
+ //# sourceMappingURL=prefixDiagnostics.d.ts.map