d365fo-mcp 1.8.2 → 1.8.3

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.
@@ -28,6 +28,7 @@ import { validateEdtExtensionChange } from '../utils/edtExtensionValidator.js';
28
28
  import { lintXppSelect } from '../utils/xppSelectLint.js';
29
29
  import { getRequiredParams, renderOpSpec, OP_PARAM_ALIASES, findIgnoredParams, renderIgnoredParamsWarning, findMissingMutationParams, } from './d365foFileOpSpecs.js';
30
30
  import { lookupSymbolNocase } from '../utils/symbolLookup.js';
31
+ import { crossModelWriteRefusal, baseObjectOf, } from '../utils/crossModelWriteGuard.js';
31
32
  /**
32
33
  * Decode the standard XML entities (<, >, ', ", &) and normalise
33
34
  * line endings by stripping xml2js's 
 representation of carriage return.
@@ -1449,6 +1450,26 @@ export async function modifyD365FileTool(request, context) {
1449
1450
  ` • Form: d365fo_file(action="create", objectType="form-extension", objectName="${objectName}.${configuredModel || 'YourModel'}Extension")`);
1450
1451
  }
1451
1452
  }
1453
+ // 1c. Cross-model guard: the object is custom, but owned by a DIFFERENT custom
1454
+ // model than the one this workspace targets (shared "Core" model vs. the country
1455
+ // model that extends it). Editing it in place silently changes code the active
1456
+ // model only consumes — the wanted change is an extension in the active model.
1457
+ // Ownership comes from the path's <Model> segment, not the <Package> segment
1458
+ // used above: one package can carry several models.
1459
+ const owningModel = containment.modelSegment ?? null;
1460
+ const activeModel = getConfigManager().getModelName() ?? '';
1461
+ const crossModelRefusal = crossModelWriteRefusal({
1462
+ objectName,
1463
+ objectType,
1464
+ owningModel,
1465
+ owningPackage: containment.packageSegment ?? resolvedModelFromPath,
1466
+ activeModel,
1467
+ explicitModelName: modelName,
1468
+ existingExtensions: findExtensionsInModel(symbolIndex, baseObjectOf(objectName, objectType), activeModel),
1469
+ });
1470
+ if (crossModelRefusal) {
1471
+ throw new Error(crossModelRefusal);
1472
+ }
1452
1473
  // 2. Resolve actual XML file path (DB may store JSON metadata with sourcePath)
1453
1474
  let actualFilePath = filePath;
1454
1475
  try {
@@ -1516,7 +1537,11 @@ export async function modifyD365FileTool(request, context) {
1516
1537
  // spells this out ("Fields in extensions → {Prefix}{FieldName}") and BP
1517
1538
  // rejects the unprefixed form. This is applied once, here, so every writer
1518
1539
  // below (bridge op and direct-XML fallback alike) sees the final name.
1519
- const memberPrefixNote = applyExtensionMemberPrefix(args, objectType, operation, resolvedModelFromPath || modelName || getConfigManager().getModelName() || '');
1540
+ const memberPrefixNote = applyExtensionMemberPrefix(args, objectType, operation,
1541
+ // The MODEL segment, not the package: members added to an extension must
1542
+ // carry the prefix of the model that owns the extension file (ContosoFinanceSK
1543
+ // → ContosoSK_), and a package can hold a model whose prefix differs.
1544
+ containment.modelSegment || resolvedModelFromPath || modelName || getConfigManager().getModelName() || '');
1520
1545
  if (memberPrefixNote)
1521
1546
  generationNote += memberPrefixNote;
1522
1547
  let bridgeResult = null;
@@ -2218,6 +2243,34 @@ const EXTENSION_MEMBER_NAME_ARG = {
2218
2243
  'add-field-group': 'fieldGroupName',
2219
2244
  'add-enum-value': 'enumValueName',
2220
2245
  };
2246
+ /**
2247
+ * Extensions of `baseObject` that already live in `model` — so the cross-model
2248
+ * refusal can point at the extension the active model ALREADY has instead of
2249
+ * telling the agent to create a second one next to it.
2250
+ *
2251
+ * Reads `extension_metadata`, which is indexed on base_object_name and small
2252
+ * enough that COLLATE NOCASE here costs nothing (unlike the 1M-row symbols
2253
+ * table). Best-effort: a missing table or unbuilt index yields no suggestions,
2254
+ * never an error — the refusal itself does not depend on it.
2255
+ */
2256
+ function findExtensionsInModel(symbolIndex, baseObject, model) {
2257
+ if (!baseObject || !model)
2258
+ return [];
2259
+ try {
2260
+ const rdb = symbolIndex?.getReadDb?.();
2261
+ if (!rdb)
2262
+ return [];
2263
+ const rows = rdb.prepare(`SELECT extension_name AS name, extension_type AS type
2264
+ FROM extension_metadata
2265
+ WHERE base_object_name = ? COLLATE NOCASE
2266
+ AND model = ? COLLATE NOCASE
2267
+ LIMIT 5`).all(baseObject, model);
2268
+ return rows ?? [];
2269
+ }
2270
+ catch {
2271
+ return [];
2272
+ }
2273
+ }
2221
2274
  /**
2222
2275
  * Prefix the new member name this operation carries, when writing into an
2223
2276
  * extension. Mutates `args` in place and returns a note for the response (empty
@@ -4,7 +4,7 @@
4
4
  * detect conflicts against the symbol index, and suggest correct names.
5
5
  */
6
6
  import { z } from 'zod';
7
- import { getObjectSuffix, getExtensionNamingStyle } from '../utils/modelClassifier.js';
7
+ import { getObjectSuffix, getExtensionNamingStyle, resolveObjectPrefix, deriveExtensionInfix, } from '../utils/modelClassifier.js';
8
8
  import { getConfigManager } from '../utils/configManager.js';
9
9
  import { lookupSymbolNocase, lookupSymbolsNocase } from '../utils/symbolLookup.js';
10
10
  const ValidateObjectNamingArgsSchema = z.object({
@@ -46,26 +46,25 @@ export async function validateObjectNamingTool(request, context) {
46
46
  else if (name.length > 70) {
47
47
  warnings.push(`Name is ${name.length} characters — approaching the ${MAX_NAME_LENGTH}-char AOT limit. Consider a shorter name to leave room for extensions.`);
48
48
  }
49
- // Resolve model prefix: explicit arg EXTENSION_PREFIX env DB auto-detect (mirrors resolveObjectPrefix()).
50
- let prefix = args.modelPrefix?.toUpperCase() || '';
51
- if (!prefix) {
52
- const envPrefix = process.env.EXTENSION_PREFIX?.trim().replace(/_+$/, '');
53
- if (envPrefix) {
54
- prefix = envPrefix.toUpperCase();
55
- }
56
- else {
57
- prefix = detectModelPrefix(db, name);
58
- }
59
- }
60
- // Under EXTENSION_NAMING_STYLE=model-name the extension token is the model name (Visual Studio
61
- // default) rather than the prefix infix: class extension → {Base}_{ModelName}_Extension,
62
- // element extension → {Base}.{ModelName}. Explicit modelName arg wins over workspace config.
49
+ // The model whose convention is being validated against: explicit arg, else the
50
+ // model this workspace targets. It decides the prefix, so it is resolved BEFORE
51
+ // the prefix — a workspace on ContosoFinanceSK must validate against ContosoSK even
52
+ // when EXTENSION_PREFIX still says something else.
63
53
  const namingStyle = getExtensionNamingStyle();
64
- let modelName = args.modelName?.trim() || '';
65
- if (!modelName && namingStyle === 'model-name') {
66
- modelName = getConfigManager().getModelName() ?? '';
67
- }
54
+ const modelName = args.modelName?.trim() || getConfigManager().getModelName() || '';
68
55
  const useModelName = namingStyle === 'model-name' && !!modelName;
56
+ // Resolve model prefix: explicit arg → the active model's own prefix
57
+ // (resolveObjectPrefix: learned from that model's objects, else EXTENSION_PREFIX,
58
+ // else the model name) → DB auto-detect for an unconfigured workspace.
59
+ // NOT upper-cased: "ContosoFin" is a PascalCase prefix, and "CONTOSOFIN" would make every
60
+ // suggested name and every startsWith() check below wrong.
61
+ let prefix = args.modelPrefix?.trim() || '';
62
+ if (!prefix) {
63
+ prefix = resolveObjectPrefix(modelName) || detectModelPrefix(db, name);
64
+ }
65
+ // Token embedded in extension element/class names — the model's own infix when its
66
+ // existing extensions state one (ContosoFinanceSK → "ContosoSK"), else derived.
67
+ const extensionInfix = prefix ? deriveExtensionInfix(prefix, modelName) : '';
69
68
  // Rule set 1: extension naming rules
70
69
  if (isExtension) {
71
70
  const baseObjectName = args.baseObjectName;
@@ -77,8 +76,8 @@ export async function validateObjectNamingTool(request, context) {
77
76
  // prefix style → {Base}{Prefix}_Extension; model-name style → {Base}_{ModelName}_Extension
78
77
  const expectedPattern = useModelName
79
78
  ? `${baseObjectName}_${modelName}_Extension`
80
- : `${baseObjectName}${prefix}_Extension`;
81
- const expectedToken = useModelName ? modelName : prefix;
79
+ : `${baseObjectName}${extensionInfix}_Extension`;
80
+ const expectedToken = useModelName ? modelName : extensionInfix;
82
81
  if (!name.startsWith(baseObjectName)) {
83
82
  errors.push(`Class extension names must start with the base class name.\n Expected format: ${expectedPattern}`);
84
83
  if (expectedToken)
@@ -98,12 +97,12 @@ export async function validateObjectNamingTool(request, context) {
98
97
  !middle.toLowerCase().includes(expectedToken.toLowerCase())) {
99
98
  warnings.push(useModelName
100
99
  ? `Extension name does not embed the model name "${modelName}" (EXTENSION_NAMING_STYLE=model-name).\n Current: ${name}\n Recommended: ${expectedPattern}`
101
- : `Extension name does not include model prefix "${prefix}".\n Current: ${name}\n Recommended: ${expectedPattern}`);
100
+ : `Extension name does not include model "${modelName || '(unknown)'}"'s extension infix "${extensionInfix}".\n Current: ${name}\n Recommended: ${expectedPattern}`);
102
101
  }
103
102
  }
104
103
  suggestions.push(useModelName
105
104
  ? `AOT name for an element extension instead: ${baseObjectName}.${modelName}`
106
- : `AOT label for extension file: ${baseObjectName}.${prefix}Extension (if creating table-extension AOT object instead)`);
105
+ : `AOT label for extension file: ${baseObjectName}.${extensionInfix}Extension (if creating table-extension AOT object instead)`);
107
106
  }
108
107
  else if (useModelName) {
109
108
  // AOT extensions (table/form/enum/edt), model-name style: {Base}.{ModelName} — bare model
@@ -124,8 +123,8 @@ export async function validateObjectNamingTool(request, context) {
124
123
  }
125
124
  }
126
125
  else {
127
- // AOT extensions (table/form/enum/edt), prefix style: {Base}.{Prefix}Extension.
128
- const expectedPattern = `${baseObjectName}.${prefix}Extension`;
126
+ // AOT extensions (table/form/enum/edt), prefix style: {Base}.{Infix}Extension.
127
+ const expectedPattern = `${baseObjectName}.${extensionInfix}Extension`;
129
128
  if (!name.includes('.')) {
130
129
  errors.push(`${args.objectType} names must use dot notation: {Base}.{Prefix}Extension.\n Expected: ${expectedPattern}`);
131
130
  if (prefix)
@@ -137,10 +136,10 @@ export async function validateObjectNamingTool(request, context) {
137
136
  errors.push(`Extension base (before '.') must exactly match baseObjectName.\n Expected: ${baseObjectName}.xxx\n Got: ${basePart}.xxx`);
138
137
  }
139
138
  if (!extPart.endsWith('Extension')) {
140
- errors.push(`Extension suffix (after '.') must end with 'Extension'.\n Expected: ${prefix}Extension\n Got: ${extPart}`);
139
+ errors.push(`Extension suffix (after '.') must end with 'Extension'.\n Expected: ${extensionInfix}Extension\n Got: ${extPart}`);
141
140
  }
142
- else if (prefix && !extPart.startsWith(prefix)) {
143
- warnings.push(`Extension suffix should start with model prefix "${prefix}".\n Current: ${extPart}\n Recommended: ${prefix}Extension`);
141
+ else if (extensionInfix && !extPart.toLowerCase().startsWith(extensionInfix.toLowerCase())) {
142
+ warnings.push(`Extension suffix should start with model "${modelName || '(unknown)'}"'s infix "${extensionInfix}".\n Current: ${extPart}\n Recommended: ${extensionInfix}Extension`);
144
143
  }
145
144
  }
146
145
  }
@@ -173,7 +172,7 @@ export async function validateObjectNamingTool(request, context) {
173
172
  }
174
173
  }
175
174
  if (prefix) {
176
- if (!name.startsWith(prefix) && !name.toUpperCase().startsWith(prefix)) {
175
+ if (!name.toLowerCase().startsWith(prefix.toLowerCase())) {
177
176
  warnings.push(`Proposed name does not start with model prefix "${prefix}". All custom objects should be prefixed to avoid conflicts.`);
178
177
  suggestions.push(`Prefixed name: ${prefix}${name}`);
179
178
  }
@@ -220,12 +219,13 @@ export async function validateObjectNamingTool(request, context) {
220
219
  let output = `Validation: "${name}" as ${args.objectType}\n`;
221
220
  if (args.baseObjectName)
222
221
  output += `Base Object: ${args.baseObjectName}\n`;
223
- if (prefix)
224
- output += `Model Prefix: ${prefix}\n`;
222
+ if (prefix) {
223
+ output += `Model Prefix: ${prefix}${modelName ? ` (model ${modelName})` : ''}\n`;
224
+ }
225
225
  if (isExtension) {
226
226
  output += useModelName
227
227
  ? `Extension Style: model-name (token = model name "${modelName}")\n`
228
- : `Extension Style: prefix (token = model prefix infix)\n`;
228
+ : `Extension Style: prefix (token = "${extensionInfix}")\n`;
229
229
  if (namingStyle === 'model-name' && !modelName) {
230
230
  output += ` ⚠ EXTENSION_NAMING_STYLE=model-name but no model name could be resolved — validated structure only. Pass modelName to validate the extension token.\n`;
231
231
  }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Cross-model write guard.
3
+ *
4
+ * `modify` already refuses to touch objects in standard Microsoft models
5
+ * (isStandardModel), but that check says nothing about the far more common
6
+ * real-world layout: a customer solution split across several CUSTOM models,
7
+ * e.g. a shared `ContosoFinanceCore` plus country models `ContosoFinanceSK` /
8
+ * `ContosoFinanceCZ` that extend it. The workspace's .rnrproj names exactly ONE of
9
+ * them as the target model; every other model is somebody else's code as far as
10
+ * this workspace is concerned.
11
+ *
12
+ * Without this guard the failure is silent and expensive: asked to "add a field
13
+ * to <table>", the agent resolves the table by name, lands in the shared model
14
+ * that happens to own it, and edits it in place. The field is then invisible in
15
+ * the active model's project and version control, and it lands in code every
16
+ * other country model inherits — instead of the one thing that was wanted, a
17
+ * table extension in the active model.
18
+ *
19
+ * So a write whose resolved file lives in a model other than the active one is
20
+ * refused by default, with the extension route spelled out. Two deliberate
21
+ * escape hatches, mirroring the standard-model guard's "explicit modelName =
22
+ * you know what you're doing":
23
+ * - `modelName="<owning model>"` on the call — a per-call, per-model opt-in,
24
+ * - `D365FO_ALLOW_CROSS_MODEL_WRITE=true` — an environment-wide opt-out for
25
+ * setups where one server really does serve several models.
26
+ */
27
+ /** An extension of the target object that already exists in the active model. */
28
+ export interface ExistingExtension {
29
+ name: string;
30
+ type: string;
31
+ }
32
+ export interface CrossModelWriteCheck {
33
+ /** Object being written, as resolved (may already be an extension). */
34
+ objectName: string;
35
+ /** d365fo_file objectType, e.g. 'table', 'table-extension', 'class'. */
36
+ objectType: string;
37
+ /** Model that owns the resolved file (the `<Model>` path segment). */
38
+ owningModel: string | null | undefined;
39
+ /**
40
+ * `<Package>` segment of the same path. A match on EITHER segment counts as
41
+ * "same model": most custom models sit in a package of the same name, but a
42
+ * configured model name occasionally matches only the package (several models
43
+ * in one package, or a model folder named after the package). Accepting both
44
+ * keeps the guard from firing on the workspace's own objects.
45
+ */
46
+ owningPackage?: string | null;
47
+ /** Model the workspace targets (.rnrproj / D365FO_MODEL_NAME). */
48
+ activeModel: string | null | undefined;
49
+ /** `modelName` as passed by the caller — the per-call opt-in. */
50
+ explicitModelName?: string | null;
51
+ /** Extensions of the base object that already exist in the active model. */
52
+ existingExtensions?: ExistingExtension[];
53
+ }
54
+ /** The base object an extension extends: "CustTable.FooExtension" → "CustTable". */
55
+ export declare function baseObjectOf(objectName: string, objectType: string): string;
56
+ /**
57
+ * The name the extension WOULD get in `activeModel`, following that model's own
58
+ * naming (prefix inference + EXTENSION_NAMING_STYLE), or null when the type has
59
+ * no extension form. Class extensions use the `{Base}{Infix}_Extension` shape;
60
+ * everything else the dot-notation element form.
61
+ */
62
+ export declare function suggestedExtensionName(baseObject: string, baseType: string, activeModel: string): string | null;
63
+ /**
64
+ * Refusal message for a write into a model other than the active one, or null
65
+ * when the write is allowed.
66
+ */
67
+ export declare function crossModelWriteRefusal(check: CrossModelWriteCheck): string | null;
68
+ //# sourceMappingURL=crossModelWriteGuard.d.ts.map
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Cross-model write guard.
3
+ *
4
+ * `modify` already refuses to touch objects in standard Microsoft models
5
+ * (isStandardModel), but that check says nothing about the far more common
6
+ * real-world layout: a customer solution split across several CUSTOM models,
7
+ * e.g. a shared `ContosoFinanceCore` plus country models `ContosoFinanceSK` /
8
+ * `ContosoFinanceCZ` that extend it. The workspace's .rnrproj names exactly ONE of
9
+ * them as the target model; every other model is somebody else's code as far as
10
+ * this workspace is concerned.
11
+ *
12
+ * Without this guard the failure is silent and expensive: asked to "add a field
13
+ * to <table>", the agent resolves the table by name, lands in the shared model
14
+ * that happens to own it, and edits it in place. The field is then invisible in
15
+ * the active model's project and version control, and it lands in code every
16
+ * other country model inherits — instead of the one thing that was wanted, a
17
+ * table extension in the active model.
18
+ *
19
+ * So a write whose resolved file lives in a model other than the active one is
20
+ * refused by default, with the extension route spelled out. Two deliberate
21
+ * escape hatches, mirroring the standard-model guard's "explicit modelName =
22
+ * you know what you're doing":
23
+ * - `modelName="<owning model>"` on the call — a per-call, per-model opt-in,
24
+ * - `D365FO_ALLOW_CROSS_MODEL_WRITE=true` — an environment-wide opt-out for
25
+ * setups where one server really does serve several models.
26
+ */
27
+ import { resolveObjectPrefix, applyObjectPrefix } from './modelClassifier.js';
28
+ /** Base object type → the d365fo_file objectType used to extend it. */
29
+ const EXTENSION_TYPE_OF = {
30
+ table: 'table-extension',
31
+ form: 'form-extension',
32
+ enum: 'enum-extension',
33
+ edt: 'edt-extension',
34
+ view: 'view-extension',
35
+ query: 'query-extension',
36
+ map: 'map-extension',
37
+ 'data-entity': 'data-entity-extension',
38
+ menu: 'menu-extension',
39
+ class: 'class-extension',
40
+ };
41
+ function eq(a, b) {
42
+ return !!a && !!b && a.trim().toLowerCase() === b.trim().toLowerCase();
43
+ }
44
+ /** True when the operator has opted out of the guard environment-wide. */
45
+ function guardDisabled() {
46
+ const v = process.env.D365FO_ALLOW_CROSS_MODEL_WRITE?.trim().toLowerCase();
47
+ return v === 'true' || v === '1' || v === 'yes';
48
+ }
49
+ /** The base object an extension extends: "CustTable.FooExtension" → "CustTable". */
50
+ export function baseObjectOf(objectName, objectType) {
51
+ if (objectName.includes('.'))
52
+ return objectName.slice(0, objectName.indexOf('.'));
53
+ if (objectType === 'class-extension' && objectName.endsWith('_Extension')) {
54
+ return objectName.slice(0, -'_Extension'.length);
55
+ }
56
+ return objectName;
57
+ }
58
+ /**
59
+ * The name the extension WOULD get in `activeModel`, following that model's own
60
+ * naming (prefix inference + EXTENSION_NAMING_STYLE), or null when the type has
61
+ * no extension form. Class extensions use the `{Base}{Infix}_Extension` shape;
62
+ * everything else the dot-notation element form.
63
+ */
64
+ export function suggestedExtensionName(baseObject, baseType, activeModel) {
65
+ if (!EXTENSION_TYPE_OF[baseType])
66
+ return null;
67
+ const prefix = resolveObjectPrefix(activeModel);
68
+ if (!prefix)
69
+ return null;
70
+ return baseType === 'class'
71
+ ? applyObjectPrefix(`${baseObject}_Extension`, prefix, activeModel)
72
+ : applyObjectPrefix(`${baseObject}.Extension`, prefix, activeModel);
73
+ }
74
+ /**
75
+ * Refusal message for a write into a model other than the active one, or null
76
+ * when the write is allowed.
77
+ */
78
+ export function crossModelWriteRefusal(check) {
79
+ const { objectName, objectType, owningModel, activeModel, explicitModelName } = check;
80
+ // Nothing to compare against — an unconfigured workspace or a path whose model
81
+ // segment could not be determined. Never block on a guess.
82
+ if (!owningModel || !activeModel)
83
+ return null;
84
+ if (eq(owningModel, activeModel) || eq(check.owningPackage, activeModel))
85
+ return null;
86
+ // Per-call opt-in: the caller named the owning model (or its package) outright.
87
+ if (eq(explicitModelName, owningModel) || eq(explicitModelName, check.owningPackage))
88
+ return null;
89
+ if (guardDisabled()) {
90
+ console.error(`[crossModelWriteGuard] D365FO_ALLOW_CROSS_MODEL_WRITE — allowing write to "${objectName}" ` +
91
+ `in model "${owningModel}" (active model "${activeModel}")`);
92
+ return null;
93
+ }
94
+ const isExtension = objectType.endsWith('-extension');
95
+ const baseObject = baseObjectOf(objectName, objectType);
96
+ const baseType = isExtension ? objectType.slice(0, -'-extension'.length) : objectType;
97
+ const extType = EXTENSION_TYPE_OF[baseType];
98
+ const lines = [
99
+ `⛔ Refusing to modify "${objectName}" — it belongs to model "${owningModel}", ` +
100
+ `not to this workspace's model "${activeModel}".`,
101
+ '',
102
+ `"${owningModel}" is a different model: the change would land in code that "${activeModel}" ` +
103
+ `only consumes, it would not appear in this workspace's project or version control, and every ` +
104
+ `other model built on "${owningModel}" would inherit it.`,
105
+ '',
106
+ ];
107
+ if (extType) {
108
+ const existing = (check.existingExtensions ?? []).filter(e => !eq(e.name, objectName));
109
+ lines.push(`Extend it from "${activeModel}" instead:`);
110
+ if (existing.length > 0) {
111
+ lines.push(` • "${activeModel}" already extends ${baseObject} — add to that extension:`, ...existing.slice(0, 5).map(e => ` d365fo_file(action="modify", objectType="${extType}", objectName="${e.name}", modelName="${activeModel}", operation=…)`));
112
+ }
113
+ else {
114
+ const suggested = suggestedExtensionName(baseObject, baseType, activeModel);
115
+ lines.push(` • no extension of ${baseObject} exists in "${activeModel}" yet — create one:`, ` d365fo_file(action="create", objectType="${extType}", objectName="${suggested ?? `${baseObject}.<Prefix>Extension`}", modelName="${activeModel}")`, ` then add the member to it with action="modify".`);
116
+ }
117
+ lines.push('');
118
+ }
119
+ lines.push(`If editing "${owningModel}" in place is genuinely what you want, say so explicitly:`, ` • pass modelName="${owningModel}" on this call, or`, ` • switch the workspace to that model: get_workspace_info(projectName="${owningModel}"), or`, ` • set D365FO_ALLOW_CROSS_MODEL_WRITE=true to disable this guard for the whole server.`);
120
+ return lines.join('\n');
121
+ }
122
+ //# sourceMappingURL=crossModelWriteGuard.js.map
@@ -24,6 +24,15 @@ export interface PathContainmentResult {
24
24
  canonicalPath?: string;
25
25
  /** Matched root (for diagnostics). */
26
26
  matchedRoot?: string;
27
+ /** `<Package>` segment of the canonical layout, when ok. */
28
+ packageSegment?: string;
29
+ /**
30
+ * `<Model>` segment of the canonical layout, when ok — the model that actually
31
+ * OWNS the file. Package and model differ whenever one package carries several
32
+ * models, so ownership decisions (see crossModelWriteGuard) must read this and
33
+ * not the package name.
34
+ */
35
+ modelSegment?: string;
27
36
  }
28
37
  /**
29
38
  * Validate that `filePath` points at a D365FO AOT file inside an allowed root
@@ -195,7 +195,7 @@ export async function assertWritePathAllowed(filePath, modelHint, opts) {
195
195
  reason: `Path does not match canonical AOT layout (<Package>/<Model>/Ax<Type>/<File>):\n ${filePath}`,
196
196
  };
197
197
  }
198
- const [, modelSeg, axFolder, lastSeg] = parts;
198
+ const [packageSeg, modelSeg, axFolder, lastSeg] = parts;
199
199
  if (!/^Ax[A-Z]/.test(axFolder)) {
200
200
  return {
201
201
  ok: false,
@@ -218,7 +218,13 @@ export async function assertWritePathAllowed(filePath, modelHint, opts) {
218
218
  };
219
219
  }
220
220
  }
221
- return { ok: true, canonicalPath: canonical, matchedRoot };
221
+ return {
222
+ ok: true,
223
+ canonicalPath: canonical,
224
+ matchedRoot,
225
+ packageSegment: packageSeg,
226
+ modelSegment: modelSeg,
227
+ };
222
228
  }
223
229
  /** Throwing wrapper — convenient in tool handlers. */
224
230
  export async function ensureWritePathAllowed(filePath, modelHint, opts) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "d365fo-mcp",
3
- "version": "1.8.2",
3
+ "version": "1.8.3",
4
4
  "description": "MCP Server for X++ Code Completion in D365 Finance & Operations",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",