d365fo-mcp 1.8.3 → 1.8.4

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.
@@ -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,24 @@ 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().getModelName() ?? '',
3810
+ action: 'create',
3811
+ });
3812
+ if (crossModelCreateRefusal) {
3813
+ return {
3814
+ content: [{ type: 'text', text: crossModelCreateRefusal }],
3815
+ isError: true,
3816
+ };
3817
+ }
3799
3818
  // Apply extension prefix to object name
3800
3819
  const objectPrefix = resolveObjectPrefix(actualModelName);
3801
3820
  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,25 @@ 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.getModelName() ?? '',
460
+ action: 'create',
461
+ });
462
+ if (crossModelLabelRefusal) {
463
+ return {
464
+ content: [{ type: 'text', text: crossModelLabelRefusal }],
465
+ isError: true,
466
+ };
467
+ }
448
468
  const modelDir = path.join(resolvedPackagePath, resolvedPackageName, model);
449
469
  const axLabelDir = path.join(modelDir, 'AxLabelFile');
450
470
  const labelResourcesDir = path.join(axLabelDir, 'LabelResources');
@@ -1464,7 +1464,7 @@ export async function modifyD365FileTool(request, context) {
1464
1464
  owningModel,
1465
1465
  owningPackage: containment.packageSegment ?? resolvedModelFromPath,
1466
1466
  activeModel,
1467
- explicitModelName: modelName,
1467
+ action: 'modify',
1468
1468
  existingExtensions: findExtensionsInModel(symbolIndex, baseObjectOf(objectName, objectType), activeModel),
1469
1469
  });
1470
1470
  if (crossModelRefusal) {
@@ -16,13 +16,24 @@
16
16
  * other country model inherits — instead of the one thing that was wanted, a
17
17
  * table extension in the active model.
18
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.
19
+ * ## Consent cannot be self-served
20
+ *
21
+ * The first version of this guard accepted `modelName="<owning model>"` on the
22
+ * call as consent, mirroring the standard-model guard's "explicit modelName =
23
+ * you know what you're doing". For a human that reasoning holds. For an agent it
24
+ * does not: the refusal text told it which parameter to add, so it added the
25
+ * parameter and wrote into the shared model anyway — with a well-argued
26
+ * explanation afterwards. A bypass the caller can mint for itself is not a
27
+ * bypass, it is a speed bump.
28
+ *
29
+ * So consent lives ONLY in server configuration, which a human edits and which
30
+ * takes a restart:
31
+ * - `D365FO_CROSS_MODEL_WRITE_MODELS=ModelA,ModelB` — allow these models,
32
+ * - `D365FO_ALLOW_CROSS_MODEL_WRITE=true` — allow any model.
33
+ *
34
+ * And the refusal deliberately does NOT hand the caller a workaround: it names
35
+ * the extension route, and says the alternative is the user's decision to make
36
+ * in configuration.
26
37
  */
27
38
  /** An extension of the target object that already exists in the active model. */
28
39
  export interface ExistingExtension {
@@ -34,7 +45,7 @@ export interface CrossModelWriteCheck {
34
45
  objectName: string;
35
46
  /** d365fo_file objectType, e.g. 'table', 'table-extension', 'class'. */
36
47
  objectType: string;
37
- /** Model that owns the resolved file (the `<Model>` path segment). */
48
+ /** Model that owns the write target (the `<Model>` path segment, or the model asked for). */
38
49
  owningModel: string | null | undefined;
39
50
  /**
40
51
  * `<Package>` segment of the same path. A match on EITHER segment counts as
@@ -46,11 +57,17 @@ export interface CrossModelWriteCheck {
46
57
  owningPackage?: string | null;
47
58
  /** Model the workspace targets (.rnrproj / D365FO_MODEL_NAME). */
48
59
  activeModel: string | null | undefined;
49
- /** `modelName` as passed by the caller — the per-call opt-in. */
50
- explicitModelName?: string | null;
51
60
  /** Extensions of the base object that already exist in the active model. */
52
61
  existingExtensions?: ExistingExtension[];
62
+ /** Wording only — what the caller was about to do. */
63
+ action?: 'modify' | 'create';
53
64
  }
65
+ /**
66
+ * True when the OPERATOR has allowed writes into `owningModel` from another
67
+ * workspace — a blanket opt-out, or an explicit per-model allow-list. Both live
68
+ * in the environment: a caller cannot grant this to itself mid-conversation.
69
+ */
70
+ export declare function crossModelWriteAllowedByConfig(owningModel: string): boolean;
54
71
  /** The base object an extension extends: "CustTable.FooExtension" → "CustTable". */
55
72
  export declare function baseObjectOf(objectName: string, objectType: string): string;
56
73
  /**
@@ -16,13 +16,24 @@
16
16
  * other country model inherits — instead of the one thing that was wanted, a
17
17
  * table extension in the active model.
18
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.
19
+ * ## Consent cannot be self-served
20
+ *
21
+ * The first version of this guard accepted `modelName="<owning model>"` on the
22
+ * call as consent, mirroring the standard-model guard's "explicit modelName =
23
+ * you know what you're doing". For a human that reasoning holds. For an agent it
24
+ * does not: the refusal text told it which parameter to add, so it added the
25
+ * parameter and wrote into the shared model anyway — with a well-argued
26
+ * explanation afterwards. A bypass the caller can mint for itself is not a
27
+ * bypass, it is a speed bump.
28
+ *
29
+ * So consent lives ONLY in server configuration, which a human edits and which
30
+ * takes a restart:
31
+ * - `D365FO_CROSS_MODEL_WRITE_MODELS=ModelA,ModelB` — allow these models,
32
+ * - `D365FO_ALLOW_CROSS_MODEL_WRITE=true` — allow any model.
33
+ *
34
+ * And the refusal deliberately does NOT hand the caller a workaround: it names
35
+ * the extension route, and says the alternative is the user's decision to make
36
+ * in configuration.
26
37
  */
27
38
  import { resolveObjectPrefix, applyObjectPrefix } from './modelClassifier.js';
28
39
  /** Base object type → the d365fo_file objectType used to extend it. */
@@ -41,10 +52,18 @@ const EXTENSION_TYPE_OF = {
41
52
  function eq(a, b) {
42
53
  return !!a && !!b && a.trim().toLowerCase() === b.trim().toLowerCase();
43
54
  }
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';
55
+ /**
56
+ * True when the OPERATOR has allowed writes into `owningModel` from another
57
+ * workspace — a blanket opt-out, or an explicit per-model allow-list. Both live
58
+ * in the environment: a caller cannot grant this to itself mid-conversation.
59
+ */
60
+ export function crossModelWriteAllowedByConfig(owningModel) {
61
+ const blanket = process.env.D365FO_ALLOW_CROSS_MODEL_WRITE?.trim().toLowerCase();
62
+ if (blanket === 'true' || blanket === '1' || blanket === 'yes')
63
+ return true;
64
+ return (process.env.D365FO_CROSS_MODEL_WRITE_MODELS ?? '')
65
+ .split(',')
66
+ .some(m => eq(m, owningModel));
48
67
  }
49
68
  /** The base object an extension extends: "CustTable.FooExtension" → "CustTable". */
50
69
  export function baseObjectOf(objectName, objectType) {
@@ -76,19 +95,17 @@ export function suggestedExtensionName(baseObject, baseType, activeModel) {
76
95
  * when the write is allowed.
77
96
  */
78
97
  export function crossModelWriteRefusal(check) {
79
- const { objectName, objectType, owningModel, activeModel, explicitModelName } = check;
98
+ const { objectName, objectType, owningModel, activeModel } = check;
99
+ const verb = check.action ?? 'modify';
80
100
  // Nothing to compare against — an unconfigured workspace or a path whose model
81
101
  // segment could not be determined. Never block on a guess.
82
102
  if (!owningModel || !activeModel)
83
103
  return null;
84
104
  if (eq(owningModel, activeModel) || eq(check.owningPackage, activeModel))
85
105
  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}")`);
106
+ if (crossModelWriteAllowedByConfig(owningModel)) {
107
+ console.error(`[crossModelWriteGuard] configuration allows cross-model writes to "${owningModel}" ` +
108
+ `— proceeding with ${verb} of "${objectName}" (active model "${activeModel}")`);
92
109
  return null;
93
110
  }
94
111
  const isExtension = objectType.endsWith('-extension');
@@ -96,8 +113,8 @@ export function crossModelWriteRefusal(check) {
96
113
  const baseType = isExtension ? objectType.slice(0, -'-extension'.length) : objectType;
97
114
  const extType = EXTENSION_TYPE_OF[baseType];
98
115
  const lines = [
99
- `⛔ Refusing to modify "${objectName}" it belongs to model "${owningModel}", ` +
100
- `not to this workspace's model "${activeModel}".`,
116
+ `⛔ Refusing to ${verb} "${objectName}" in model "${owningModel}" — this workspace ` +
117
+ `targets model "${activeModel}".`,
101
118
  '',
102
119
  `"${owningModel}" is a different model: the change would land in code that "${activeModel}" ` +
103
120
  `only consumes, it would not appear in this workspace's project or version control, and every ` +
@@ -116,7 +133,12 @@ export function crossModelWriteRefusal(check) {
116
133
  }
117
134
  lines.push('');
118
135
  }
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.`);
136
+ lines.push(`Do NOT route around this guard: no retry with a different modelName, filePath, ` +
137
+ `packagePath or project. Half-finished pieces of this very feature sitting in ` +
138
+ `"${owningModel}" — a matching enum, field, label or scaffold — are evidence that an ` +
139
+ `earlier run made this same mistake, NOT evidence that the feature belongs there.`, '', `Writing into "${owningModel}" is the user's decision, not yours. If they want it, they ` +
140
+ `set D365FO_CROSS_MODEL_WRITE_MODELS=${owningModel} (or D365FO_ALLOW_CROSS_MODEL_WRITE=true) ` +
141
+ `in the server configuration and restart. Ask them; report this refusal instead of working past it.`);
120
142
  return lines.join('\n');
121
143
  }
122
144
  //# sourceMappingURL=crossModelWriteGuard.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "d365fo-mcp",
3
- "version": "1.8.3",
3
+ "version": "1.8.4",
4
4
  "description": "MCP Server for X++ Code Completion in D365 Finance & Operations",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",