d365fo-mcp 1.2.0 → 1.4.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.
Files changed (77) hide show
  1. package/README.md +1 -1
  2. package/bridge/D365MetadataBridge/Protocol/RequestDispatcher.cs +8 -2
  3. package/bridge/D365MetadataBridge/Services/MetadataWriteService.cs +182 -36
  4. package/dist/bridge/bridgeAdapter.d.ts +34 -1
  5. package/dist/bridge/bridgeAdapter.js +71 -9
  6. package/dist/bridge/bridgeClient.d.ts +15 -2
  7. package/dist/bridge/bridgeClient.js +17 -3
  8. package/dist/cli/commands/doctor.js +43 -25
  9. package/dist/cli/commands/indexCmd.js +9 -3
  10. package/dist/cli/commands/instance.js +10 -1
  11. package/dist/cli/instances.d.ts +20 -0
  12. package/dist/cli/instances.js +50 -5
  13. package/dist/cli/settingsPrompt.js +24 -9
  14. package/dist/cli/settingsStore.d.ts +1 -1
  15. package/dist/cli/settingsStore.js +5 -3
  16. package/dist/config/configFile.d.ts +26 -0
  17. package/dist/config/configFile.js +36 -1
  18. package/dist/database/download.js +1 -1
  19. package/dist/database/nodeSqlite.d.ts +21 -0
  20. package/dist/database/nodeSqlite.js +37 -0
  21. package/dist/database/sqlite.d.ts +80 -0
  22. package/dist/database/sqlite.js +150 -0
  23. package/dist/index.js +1 -1
  24. package/dist/metadata/symbolCountsWorker.d.ts +1 -1
  25. package/dist/metadata/symbolCountsWorker.js +2 -2
  26. package/dist/metadata/symbolIndex.d.ts +89 -14
  27. package/dist/metadata/symbolIndex.js +198 -18
  28. package/dist/scripts/build-database.js +345 -26
  29. package/dist/scripts/build-fts.js +335 -25
  30. package/dist/scripts/extract-metadata.js +19 -2
  31. package/dist/scripts/symbolCountsWorker.js +134 -2
  32. package/dist/server/toolSchemas/d365foFile.js +3 -0
  33. package/dist/server/toolSchemas/generateObject.d.ts +4 -0
  34. package/dist/server/toolSchemas/generateObject.js +2 -1
  35. package/dist/server/toolSchemas/index.d.ts +4 -0
  36. package/dist/server/toolSchemas/prepare.js +1 -0
  37. package/dist/server/toolSchemas/triggerDbSync.js +2 -2
  38. package/dist/tools/buildProject.js +19 -1
  39. package/dist/tools/createD365File.d.ts +53 -0
  40. package/dist/tools/createD365File.js +277 -2
  41. package/dist/tools/createTablePropertyHonesty.d.ts +66 -0
  42. package/dist/tools/createTablePropertyHonesty.js +203 -0
  43. package/dist/tools/d365foFileOpSpecs.d.ts +3 -2
  44. package/dist/tools/d365foFileOpSpecs.js +37 -14
  45. package/dist/tools/dbSync.d.ts +60 -1
  46. package/dist/tools/dbSync.js +169 -52
  47. package/dist/tools/generateObject.d.ts +0 -12
  48. package/dist/tools/generateSmart.d.ts +1 -7
  49. package/dist/tools/generateSmart.js +5 -1
  50. package/dist/tools/generateSmartForm.js +34 -12
  51. package/dist/tools/generateSmartTable.d.ts +8 -0
  52. package/dist/tools/generateSmartTable.js +43 -18
  53. package/dist/tools/modifyD365File.d.ts +20 -0
  54. package/dist/tools/modifyD365File.js +142 -19
  55. package/dist/tools/prepareCreate.d.ts +5 -0
  56. package/dist/tools/prepareCreate.js +1 -0
  57. package/dist/tools/queryViewXml.js +43 -14
  58. package/dist/tools/resolveReferences.d.ts +1 -1
  59. package/dist/tools/search.d.ts +19 -0
  60. package/dist/tools/search.js +52 -4
  61. package/dist/tools/validateXpp.js +7 -7
  62. package/dist/tools/xppKnowledge.js +501 -4
  63. package/dist/utils/axFormDesignProperties.d.ts +25 -0
  64. package/dist/utils/axFormDesignProperties.js +78 -0
  65. package/dist/utils/exactMatchRanking.d.ts +19 -0
  66. package/dist/utils/exactMatchRanking.js +28 -0
  67. package/dist/utils/extractManifest.d.ts +10 -5
  68. package/dist/utils/fieldControlTypes.d.ts +25 -2
  69. package/dist/utils/fieldControlTypes.js +109 -3
  70. package/dist/utils/loadEnv.d.ts +2 -0
  71. package/dist/utils/loadEnv.js +11 -1
  72. package/dist/utils/metadataResolver.d.ts +2 -2
  73. package/dist/utils/metadataResolver.js +2 -2
  74. package/dist/utils/modelClassifier.js +27 -1
  75. package/dist/utils/symbolLookup.d.ts +2 -2
  76. package/dist/utils/symbolLookup.js +0 -0
  77. package/package.json +3 -5
package/README.md CHANGED
@@ -10,7 +10,7 @@
10
10
  [![TypeScript](https://img.shields.io/badge/TypeScript-6.0-blue.svg)](https://www.typescriptlang.org/)
11
11
  [![Tests](https://img.shields.io/badge/tests-1400%2B-brightgreen.svg)](docs/TESTING.md)
12
12
  <!-- coverage-badge:start -->
13
- [![Core coverage](https://img.shields.io/badge/core_coverage-100%25-brightgreen.svg)](eval/COVERAGE.md) [![Total coverage](https://img.shields.io/badge/total_coverage-59.7%25-lightgrey.svg)](eval/COVERAGE.md)
13
+ [![Core coverage](https://img.shields.io/badge/core_coverage-100%25-brightgreen.svg)](eval/COVERAGE.md) [![Total coverage](https://img.shields.io/badge/total_coverage-64.9%25-lightgrey.svg)](eval/COVERAGE.md)
14
14
  <!-- coverage-badge:end -->
15
15
 
16
16
  *Grounded AI development for Dynamics 365 Finance & Operations — works with GitHub Copilot and Claude Code*
@@ -538,7 +538,10 @@ namespace D365MetadataBridge.Protocol
538
538
  var relatedTable = request.GetStringParam("relatedTable")
539
539
  ?? throw new ArgumentException("Missing: relatedTable");
540
540
  return _writeService!.AddRelation(tableName, relationName, relatedTable,
541
- request.GetParam<System.Collections.Generic.List<WriteRelationConstraint>>("constraints"));
541
+ request.GetParam<System.Collections.Generic.List<WriteRelationConstraint>>("constraints"),
542
+ request.GetStringParam("relationCardinality") ?? request.GetStringParam("cardinality"),
543
+ request.GetStringParam("relatedTableCardinality"),
544
+ request.GetStringParam("relationshipType"));
542
545
  });
543
546
 
544
547
  case "removerelation":
@@ -965,7 +968,10 @@ namespace D365MetadataBridge.Protocol
965
968
  writeResult = _writeService.AddRelation(objectName,
966
969
  S("relationName") ?? throw new ArgumentException("Missing: relationName"),
967
970
  S("relatedTable") ?? throw new ArgumentException("Missing: relatedTable"),
968
- op.GetTypedParam<System.Collections.Generic.List<WriteRelationConstraint>>("constraints"));
971
+ op.GetTypedParam<System.Collections.Generic.List<WriteRelationConstraint>>("constraints"),
972
+ S("relationCardinality") ?? S("cardinality"),
973
+ S("relatedTableCardinality"),
974
+ S("relationshipType"));
969
975
  break;
970
976
 
971
977
  case "removerelation":
@@ -224,11 +224,14 @@ namespace D365MetadataBridge.Services
224
224
 
225
225
  var axTable = new AxTable { Name = name };
226
226
 
227
- // Apply table-level properties (Label, TableGroup, CacheLookup, etc.)
227
+ // Apply table-level properties (Label, TableGroup, CacheLookup, etc.).
228
+ // Finding #35: an unknown key was dropped in silence — report it instead.
229
+ var unsupportedProperties = new List<string>();
228
230
  if (properties != null)
229
231
  {
230
232
  foreach (var kv in properties)
231
- SetAxTableProperty(axTable, kv.Key, kv.Value);
233
+ if (!SetAxTableProperty(axTable, kv.Key, kv.Value))
234
+ unsupportedProperties.Add(kv.Key);
232
235
  }
233
236
 
234
237
  // Add fields
@@ -318,7 +321,14 @@ namespace D365MetadataBridge.Services
318
321
  tableProvider.Create(axTable, msi);
319
322
 
320
323
  var filePath = GetExpectedPath("AxTable", name, modelName);
321
- return new { success = true, objectType = "table", objectName = name, modelName, filePath, api = "IMetaTableProvider.Create" };
324
+ return new
325
+ {
326
+ success = true, objectType = "table", objectName = name, modelName, filePath,
327
+ api = "IMetaTableProvider.Create",
328
+ appliedProperties = (properties?.Keys ?? Enumerable.Empty<string>())
329
+ .Where(k => !unsupportedProperties.Contains(k)).ToList(),
330
+ unsupportedProperties,
331
+ };
322
332
  }
323
333
 
324
334
  /// <summary>
@@ -549,10 +559,15 @@ namespace D365MetadataBridge.Services
549
559
  }
550
560
 
551
561
  // ── Apply any extra properties (overrides auto-set values if needed) ──
562
+ // Finding #35: the return value used to be thrown away, so an unknown key
563
+ // (configurationKey, …) produced a stderr line nobody reads and a ✅ to the
564
+ // caller. Collect what did NOT apply and report it in the response.
565
+ var unsupportedProperties = new List<string>();
552
566
  if (extraProperties != null)
553
567
  {
554
568
  foreach (var kv in extraProperties)
555
- SetAxTableProperty(axTable, kv.Key, kv.Value);
569
+ if (!SetAxTableProperty(axTable, kv.Key, kv.Value))
570
+ unsupportedProperties.Add(kv.Key);
556
571
  }
557
572
 
558
573
  // ── Write to disk via IMetadataProvider ──
@@ -569,6 +584,9 @@ namespace D365MetadataBridge.Services
569
584
  modelName,
570
585
  filePath,
571
586
  api = "IMetaTableProvider.Create (Smart)",
587
+ appliedProperties = (extraProperties?.Keys ?? Enumerable.Empty<string>())
588
+ .Where(k => !unsupportedProperties.Contains(k)).ToList(),
589
+ unsupportedProperties,
572
590
  bpDefaults = new
573
591
  {
574
592
  cacheLookup = axTable.CacheLookup.ToString(),
@@ -1123,7 +1141,8 @@ namespace D365MetadataBridge.Services
1123
1141
  /// AxFormDataSourceConcrete subtype can yield AxFormDataSourceReferenced (used for
1124
1142
  /// nested/referenced sources), which AxForm.AddDataSource then fails to cast.
1125
1143
  /// </summary>
1126
- private AxFormDataSourceConcrete CreateFormDataSourceRoot(string dsName, string table, string? joinSource)
1144
+ private AxFormDataSourceConcrete CreateFormDataSourceRoot(string dsName, string table, string? joinSource,
1145
+ string? linkType = null)
1127
1146
  {
1128
1147
  var assembly = typeof(AxClass).Assembly;
1129
1148
  var dsType = assembly.GetType("Microsoft.Dynamics.AX.Metadata.MetaModel.AxFormDataSourceRoot")
@@ -1136,9 +1155,51 @@ namespace D365MetadataBridge.Services
1136
1155
  ds.Name = dsName;
1137
1156
  ds.Table = table;
1138
1157
  if (!string.IsNullOrEmpty(joinSource)) ds.JoinSource = joinSource;
1158
+ // LinkType used to be accepted by the caller and then dropped here, so a join
1159
+ // reported success and serialised without it (findings #35).
1160
+ SetEnumProperty((object)ds, "LinkType", linkType);
1139
1161
  return (AxFormDataSourceConcrete)ds;
1140
1162
  }
1141
1163
 
1164
+ /// <summary>
1165
+ /// Sets an enum-typed metamodel property from its string name, reflectively.
1166
+ ///
1167
+ /// The metamodel spells these as generated enums (DataSourceLinkType_ITxt,
1168
+ /// Cardinality, RelationshipType …) whose members are the only legal values, so a
1169
+ /// typo cannot be written — but it must not be swallowed either. An unparseable
1170
+ /// value throws with the full member list rather than leaving the property at its
1171
+ /// default, which is exactly how these parameters went missing before: accepted,
1172
+ /// dropped, reported as success.
1173
+ ///
1174
+ /// A null/empty value is a no-op (the caller did not ask for the property).
1175
+ /// </summary>
1176
+ private static void SetEnumProperty(object target, string propertyName, string? value)
1177
+ {
1178
+ if (string.IsNullOrWhiteSpace(value)) return;
1179
+
1180
+ var prop = target.GetType().GetProperty(propertyName)
1181
+ ?? throw new InvalidOperationException(
1182
+ $"{target.GetType().Name} has no '{propertyName}' property in this metamodel build " +
1183
+ $"({BuildInfo.MetamodelFileVersion}).");
1184
+ var enumType = Nullable.GetUnderlyingType(prop.PropertyType) ?? prop.PropertyType;
1185
+ if (!enumType.IsEnum)
1186
+ throw new InvalidOperationException(
1187
+ $"{target.GetType().Name}.{propertyName} is {enumType.Name}, not an enum.");
1188
+
1189
+ object parsed;
1190
+ try
1191
+ {
1192
+ parsed = Enum.Parse(enumType, value!.Trim(), ignoreCase: true);
1193
+ }
1194
+ catch (ArgumentException)
1195
+ {
1196
+ throw new ArgumentException(
1197
+ $"'{value}' is not a valid {propertyName}. Valid values: " +
1198
+ string.Join(", ", Enum.GetNames(enumType)) + ".");
1199
+ }
1200
+ prop.SetValue(target, parsed);
1201
+ }
1202
+
1142
1203
  /// <summary>
1143
1204
  /// Creates a new AxMenu via DiskProvider.
1144
1205
  /// </summary>
@@ -1443,7 +1504,7 @@ namespace D365MetadataBridge.Services
1443
1504
  ?? throw new ArgumentException($"Table '{objectName}' not found");
1444
1505
  var msi = GetModelSaveInfoForObject(_provider.Tables, objectName);
1445
1506
  if (!SetAxTableProperty(obj, propertyPath, propertyValue))
1446
- throw new ArgumentException($"Unknown AxTable property '{propertyPath}' — nothing was written. Supported: label, developerDocumentation, tableGroup, cacheLookup, clusteredIndex, primaryIndex, replacementKey, saveDataPerCompany, tableType, supportInheritance, instanceRelationType, extends, titleField1, titleField2.");
1507
+ throw new ArgumentException($"Unknown AxTable property '{propertyPath}' — nothing was written. Supported: label, developerDocumentation, configurationKey, formRef, tableGroup, cacheLookup, clusteredIndex, primaryIndex, replacementKey, saveDataPerCompany, tableType, supportInheritance, instanceRelationType, extends, titleField1, titleField2.");
1447
1508
  ((IMetaTableProvider)_provider.Tables).Update(obj, msi);
1448
1509
  return new { success = true, operation = "modify-property", objectType, objectName, propertyPath, propertyValue, api = "Update" };
1449
1510
  }
@@ -1808,14 +1869,30 @@ namespace D365MetadataBridge.Services
1808
1869
  // TABLE RELATION OPERATIONS
1809
1870
  // ========================
1810
1871
 
1811
- /// <summary>Adds a relation to a table.</summary>
1812
- public object AddRelation(string tableName, string relationName, string relatedTable, List<WriteRelationConstraint>? constraints)
1872
+ /// <summary>
1873
+ /// Adds a relation to a table.
1874
+ ///
1875
+ /// Cardinality / RelatedTableCardinality / RelationshipType are real
1876
+ /// AxTableRelation properties (verified against this VM's metamodel), but this
1877
+ /// method used to set only Name, RelatedTable and the constraints — so a relation
1878
+ /// reported "✅ added" and then failed BP with
1879
+ /// BPErrorTableRelationshipPropertiesCompleteness naming exactly those three
1880
+ /// (findings #5 / #35). The TS side patched them onto the XML afterwards as a
1881
+ /// workaround; writing them through the provider is what puts them in the
1882
+ /// serialiser's own element order instead of a hand-anchored one.
1883
+ /// </summary>
1884
+ public object AddRelation(string tableName, string relationName, string relatedTable,
1885
+ List<WriteRelationConstraint>? constraints,
1886
+ string? cardinality = null, string? relatedTableCardinality = null, string? relationshipType = null)
1813
1887
  {
1814
1888
  var axTable = _provider.Tables.Read(tableName)
1815
1889
  ?? throw new ArgumentException($"Table '{tableName}' not found");
1816
1890
  var msi = GetModelSaveInfoForObject(_provider.Tables, tableName);
1817
1891
 
1818
1892
  var axRel = new AxTableRelation { Name = relationName, RelatedTable = relatedTable };
1893
+ SetEnumProperty(axRel, "Cardinality", cardinality);
1894
+ SetEnumProperty(axRel, "RelatedTableCardinality", relatedTableCardinality);
1895
+ SetEnumProperty(axRel, "RelationshipType", relationshipType);
1819
1896
  if (constraints != null)
1820
1897
  {
1821
1898
  foreach (var c in constraints)
@@ -1831,7 +1908,14 @@ namespace D365MetadataBridge.Services
1831
1908
  axTable.AddRelation(axRel);
1832
1909
 
1833
1910
  ((IMetaTableProvider)_provider.Tables).Update(axTable, msi);
1834
- return new { success = true, operation = "add-relation", objectName = tableName, relationName, relatedTable, api = "IMetaTableProvider.Update" };
1911
+ return new
1912
+ {
1913
+ success = true, operation = "add-relation", objectName = tableName, relationName, relatedTable,
1914
+ cardinality = axRel.Cardinality.ToString(),
1915
+ relatedTableCardinality = axRel.RelatedTableCardinality.ToString(),
1916
+ relationshipType = axRel.RelationshipType.ToString(),
1917
+ api = "IMetaTableProvider.Update",
1918
+ };
1835
1919
  }
1836
1920
 
1837
1921
  /// <summary>Removes a relation from a table.</summary>
@@ -1941,13 +2025,15 @@ namespace D365MetadataBridge.Services
1941
2025
  }
1942
2026
  if (target == null)
1943
2027
  throw new InvalidOperationException($"Field '{fieldName}' not found on table '{tableName}'");
2028
+ var applied = new List<string>();
1944
2029
  if (properties != null)
1945
2030
  {
1946
2031
  foreach (var kv in properties)
1947
- SetTableFieldProperty(target, kv.Key, kv.Value);
2032
+ if (SetTableFieldProperty(target, kv.Key, kv.Value)) applied.Add(kv.Key);
1948
2033
  }
2034
+ RequireSomethingApplied("modify-field", tableName, fieldName, properties, applied, SupportedTableFieldProperties);
1949
2035
  ((IMetaTableProvider)_provider.Tables).Update(axTable, msi);
1950
- return new { success = true, operation = "modify-field", objectName = tableName, fieldName, api = "IMetaTableProvider.Update" };
2036
+ return new { success = true, operation = "modify-field", objectName = tableName, fieldName, applied, api = "IMetaTableProvider.Update" };
1951
2037
  }
1952
2038
 
1953
2039
  var axExt = _provider.TableExtensions.Read(tableName);
@@ -1962,15 +2048,17 @@ namespace D365MetadataBridge.Services
1962
2048
  }
1963
2049
  if (target == null)
1964
2050
  throw new InvalidOperationException($"Field '{fieldName}' not found on table-extension '{tableName}'");
2051
+ var applied = new List<string>();
1965
2052
  if (properties != null)
1966
2053
  {
1967
2054
  foreach (var kv in properties)
1968
- SetTableFieldProperty(target, kv.Key, kv.Value);
2055
+ if (SetTableFieldProperty(target, kv.Key, kv.Value)) applied.Add(kv.Key);
1969
2056
  }
2057
+ RequireSomethingApplied("modify-field", tableName, fieldName, properties, applied, SupportedTableFieldProperties);
1970
2058
  var extProvider = _provider.TableExtensions as IMetaTableExtensionProvider
1971
2059
  ?? throw new InvalidOperationException("IMetaTableExtensionProvider not available");
1972
2060
  extProvider.Update(axExt, msi);
1973
- return new { success = true, operation = "modify-field", objectName = tableName, fieldName, api = "IMetaTableExtensionProvider.Update" };
2061
+ return new { success = true, operation = "modify-field", objectName = tableName, fieldName, applied, api = "IMetaTableExtensionProvider.Update" };
1974
2062
  }
1975
2063
 
1976
2064
  throw new ArgumentException($"Table or table-extension '{tableName}' not found");
@@ -2183,23 +2271,33 @@ namespace D365MetadataBridge.Services
2183
2271
  if (target == null)
2184
2272
  throw new InvalidOperationException($"Enum value '{valueName}' not found on enum '{enumName}'");
2185
2273
 
2274
+ var applied = new List<string>();
2186
2275
  if (properties != null)
2187
2276
  {
2188
2277
  foreach (var kv in properties)
2189
2278
  {
2190
2279
  switch (kv.Key.ToLowerInvariant())
2191
2280
  {
2192
- case "label": target.Label = kv.Value; break;
2281
+ case "label": target.Label = kv.Value; applied.Add(kv.Key); break;
2193
2282
  case "value":
2194
- if (int.TryParse(kv.Value, out var iv)) target.Value = iv;
2283
+ if (int.TryParse(kv.Value, out var iv)) { target.Value = iv; applied.Add(kv.Key); }
2284
+ break;
2285
+ case "name": target.Name = kv.Value; applied.Add(kv.Key); break;
2286
+ case "countryregioncodes": target.CountryRegionCodes = kv.Value; applied.Add(kv.Key); break;
2287
+ case "configurationkey": target.ConfigurationKey = kv.Value; applied.Add(kv.Key); break;
2288
+ default:
2289
+ Console.Error.WriteLine($"[WriteService] Unknown enum value property: {kv.Key}");
2195
2290
  break;
2196
- case "name": target.Name = kv.Value; break;
2197
2291
  }
2198
2292
  }
2199
2293
  }
2294
+ // helpText is deliberately absent: AxEnumValue has no HelpText in the
2295
+ // metamodel (only AxEnum does), so there is nothing to write it to.
2296
+ RequireSomethingApplied("modify-enum-value", enumName, valueName, properties, applied,
2297
+ new[] { "label", "value", "name", "countryRegionCodes", "configurationKey" });
2200
2298
 
2201
2299
  ((IMetaEnumProvider)_provider.Enums).Update(axEnum, msi);
2202
- return new { success = true, operation = "modify-enum-value", objectName = enumName, valueName, api = "IMetaEnumProvider.Update" };
2300
+ return new { success = true, operation = "modify-enum-value", objectName = enumName, valueName, applied, api = "IMetaEnumProvider.Update" };
2203
2301
  }
2204
2302
 
2205
2303
  /// <summary>Removes a value from an enum.</summary>
@@ -2409,10 +2507,10 @@ namespace D365MetadataBridge.Services
2409
2507
  return new { success = true, operation = "add-data-source", objectType, objectName, dsName, table, skipped = true, reason = $"data source '{(string)dyn.Name}' already binds table '{table}'", api = "IMetaFormProvider.Update" };
2410
2508
  }
2411
2509
 
2412
- axForm.AddDataSource(CreateFormDataSourceRoot(dsName, table, joinSource));
2510
+ axForm.AddDataSource(CreateFormDataSourceRoot(dsName, table, joinSource, linkType));
2413
2511
 
2414
2512
  ((IMetaFormProvider)_provider.Forms).Update(axForm, msi);
2415
- return new { success = true, operation = "add-data-source", objectType, objectName, dsName, table, api = "IMetaFormProvider.Update" };
2513
+ return new { success = true, operation = "add-data-source", objectType, objectName, dsName, table, joinSource, linkType, api = "IMetaFormProvider.Update" };
2416
2514
  }
2417
2515
  default:
2418
2516
  throw new ArgumentException($"add-data-source not supported for objectType '{objectType}' via bridge");
@@ -2518,13 +2616,25 @@ namespace D365MetadataBridge.Services
2518
2616
  {
2519
2617
  case "label": tbl.Label = value; break;
2520
2618
  case "developerdocumentation": tbl.DeveloperDocumentation = value; break;
2619
+ // ConfigurationKey / FormRef were missing here, so EVERY writer that
2620
+ // funnels through this switch (CreateTable, CreateSmartTable,
2621
+ // modify-property) discarded them while answering success — the table
2622
+ // half of finding #35. Both are plain string properties on AxTable.
2623
+ case "configurationkey": tbl.ConfigurationKey = value; break;
2624
+ case "formref": tbl.FormRef = value; break;
2521
2625
  case "tablegroup":
2522
- if (Enum.TryParse<Microsoft.Dynamics.AX.Metadata.Core.MetaModel.TableGroup>(value, true, out var tg))
2523
- tbl.TableGroup = tg;
2626
+ if (!Enum.TryParse<Microsoft.Dynamics.AX.Metadata.Core.MetaModel.TableGroup>(value, true, out var tg))
2627
+ throw new ArgumentException(
2628
+ $"'{value}' is not a valid TableGroup. Valid values: " +
2629
+ string.Join(", ", Enum.GetNames(typeof(Microsoft.Dynamics.AX.Metadata.Core.MetaModel.TableGroup))) + ".");
2630
+ tbl.TableGroup = tg;
2524
2631
  break;
2525
2632
  case "cachelookup":
2526
- if (Enum.TryParse<Microsoft.Dynamics.AX.Metadata.Core.MetaModel.RecordCacheLevel>(value, true, out var cl))
2527
- tbl.CacheLookup = cl;
2633
+ if (!Enum.TryParse<Microsoft.Dynamics.AX.Metadata.Core.MetaModel.RecordCacheLevel>(value, true, out var cl))
2634
+ throw new ArgumentException(
2635
+ $"'{value}' is not a valid CacheLookup. Valid values: " +
2636
+ string.Join(", ", Enum.GetNames(typeof(Microsoft.Dynamics.AX.Metadata.Core.MetaModel.RecordCacheLevel))) + ".");
2637
+ tbl.CacheLookup = cl;
2528
2638
  break;
2529
2639
  case "clusteredindex": tbl.ClusteredIndex = value; break;
2530
2640
  case "primaryindex": tbl.PrimaryIndex = value; break;
@@ -2533,8 +2643,11 @@ namespace D365MetadataBridge.Services
2533
2643
  tbl.SaveDataPerCompany = ParseNoYes(value);
2534
2644
  break;
2535
2645
  case "tabletype":
2536
- if (Enum.TryParse<Microsoft.Dynamics.AX.Metadata.Core.MetaModel.TableType>(value, true, out var tt))
2537
- tbl.TableType = tt;
2646
+ if (!Enum.TryParse<Microsoft.Dynamics.AX.Metadata.Core.MetaModel.TableType>(value, true, out var tt))
2647
+ throw new ArgumentException(
2648
+ $"'{value}' is not a valid TableType. Valid values: " +
2649
+ string.Join(", ", Enum.GetNames(typeof(Microsoft.Dynamics.AX.Metadata.Core.MetaModel.TableType))) + ".");
2650
+ tbl.TableType = tt;
2538
2651
  break;
2539
2652
  case "supportinheritance":
2540
2653
  tbl.SupportInheritance = ParseNoYes(value);
@@ -2926,34 +3039,67 @@ namespace D365MetadataBridge.Services
2926
3039
  }
2927
3040
 
2928
3041
  /// <summary>Sets a property on an existing table field.</summary>
2929
- private void SetTableFieldProperty(AxTableField field, string prop, string value)
3042
+ /// <summary>Table-field properties SetTableFieldProperty knows how to write.</summary>
3043
+ private static readonly string[] SupportedTableFieldProperties =
3044
+ { "label", "helpText", "mandatory", "allowEdit", "extendedDataType", "stringSize", "enumType" };
3045
+
3046
+ /// <summary>
3047
+ /// Applies one field property. Returns false when the key is unknown, or when it
3048
+ /// is known but does not apply to THIS field type (stringSize on an enum field) —
3049
+ /// the caller turns "nothing applied" into an error instead of a hollow success.
3050
+ /// </summary>
3051
+ private bool SetTableFieldProperty(AxTableField field, string prop, string value)
2930
3052
  {
2931
3053
  switch (prop.ToLowerInvariant())
2932
3054
  {
2933
- case "label": field.Label = value; break;
2934
- case "helptext": field.HelpText = value; break;
3055
+ case "label": field.Label = value; return true;
3056
+ case "helptext": field.HelpText = value; return true;
2935
3057
  case "mandatory":
2936
3058
  field.Mandatory = ParseNoYes(value);
2937
- break;
3059
+ return true;
2938
3060
  case "allowedit":
2939
3061
  field.AllowEdit = ParseNoYes(value);
2940
- break;
3062
+ return true;
2941
3063
  case "extendeddatatype":
2942
3064
  case "edt":
2943
3065
  field.ExtendedDataType = value;
2944
- break;
3066
+ return true;
2945
3067
  case "stringsize":
2946
- if (field is AxTableFieldString sf && int.TryParse(value, out var ss)) sf.StringSize = ss;
2947
- break;
3068
+ if (field is AxTableFieldString sf && int.TryParse(value, out var ss)) { sf.StringSize = ss; return true; }
3069
+ return false;
2948
3070
  case "enumtype":
2949
- if (field is AxTableFieldEnum ef) ef.EnumType = value;
2950
- break;
3071
+ if (field is AxTableFieldEnum ef) { ef.EnumType = value; return true; }
3072
+ return false;
2951
3073
  default:
2952
3074
  Console.Error.WriteLine($"[WriteService] Unknown table field property: {prop}");
2953
- break;
3075
+ return false;
2954
3076
  }
2955
3077
  }
2956
3078
 
3079
+ /// <summary>
3080
+ /// Turns a property bag that changed nothing into an error.
3081
+ ///
3082
+ /// Every modify-* op here read its properties, applied whatever it recognised,
3083
+ /// called Update() and returned success — so a caller who sent the parameters in
3084
+ /// the wrong shape (flat instead of nested under `properties`), or a key this
3085
+ /// build does not support, got "✅ modified" over a byte-identical file. The
3086
+ /// modify surface must not report a write it did not make.
3087
+ /// </summary>
3088
+ private static void RequireSomethingApplied(
3089
+ string operation, string objectName, string targetName,
3090
+ Dictionary<string, string>? properties, List<string> applied, IEnumerable<string> supported)
3091
+ {
3092
+ if (applied.Count > 0) return;
3093
+ if (properties == null || properties.Count == 0)
3094
+ throw new ArgumentException(
3095
+ $"{operation} on '{objectName}.{targetName}' was given no properties, so it would " +
3096
+ $"change nothing. Pass them under `properties`, e.g. " +
3097
+ $"properties: {{ \"{supported.First()}\": \"…\" }}. Supported: {string.Join(", ", supported)}.");
3098
+ throw new ArgumentException(
3099
+ $"{operation} on '{objectName}.{targetName}' changed nothing: none of " +
3100
+ $"[{string.Join(", ", properties.Keys)}] could be applied. Supported: {string.Join(", ", supported)}.");
3101
+ }
3102
+
2957
3103
  /// <summary>
2958
3104
  /// Recursively finds a control in the form design tree by name.
2959
3105
  /// Returns the dynamic control object (AxFormControl subclass).
@@ -22,6 +22,22 @@ export interface ToolResult {
22
22
  }
23
23
  export declare function tryBridgeTable(bridge: BridgeClient | undefined, tableName: string, methodOffset?: number): Promise<ToolResult | null>;
24
24
  export declare function tryBridgeClass(bridge: BridgeClient | undefined, className: string, compact: boolean, methodOffset?: number): Promise<ToolResult | null>;
25
+ /**
26
+ * The one-line signature shown per method in the compact class view.
27
+ *
28
+ * This used to be `source.split('\n')[0]`, which for any method whose body opens
29
+ * with an XML doc comment rendered the METHOD NAME as `/// <summary>`: on
30
+ * Foundation's CustVendVoucher, 14 of the first 15 methods came out that way
31
+ * (L2-datetime-timezone-range run). Every documented platform class was
32
+ * therefore unreadable through the default grounding path, while the sibling
33
+ * `options={members:"names"}` path answered correctly.
34
+ *
35
+ * The declaration is located with the shared X++ parser, which handles
36
+ * parameter lists wrapped across lines; only if that fails do we fall back to
37
+ * the first line that is neither blank, nor a comment, nor an attribute — and
38
+ * finally to the name the metadata itself carries, which is never wrong.
39
+ */
40
+ export declare function methodSignatureLine(name: string, source?: string): string;
25
41
  export declare function tryBridgeMethodSource(bridge: BridgeClient | undefined, className: string, methodName: string): Promise<ToolResult | null>;
26
42
  export declare function tryBridgeEnum(bridge: BridgeClient | undefined, enumName: string): Promise<ToolResult | null>;
27
43
  export declare function tryBridgeEdt(bridge: BridgeClient | undefined, edtName: string): Promise<ToolResult | null>;
@@ -55,6 +71,18 @@ export interface BridgeSearchOptions {
55
71
  name: string;
56
72
  type: string;
57
73
  }>;
74
+ /**
75
+ * Keyword hits from CUSTOM/ISV models the caller resolved from the SQLite
76
+ * index (model-scoped, FTS-driven). The bridge enumerates a single merged
77
+ * key list dominated by Microsoft standard objects and truncates at
78
+ * maxResults, so custom matches that enumerate later never reach the client.
79
+ * These are spliced in and ranked directly after the exact matches (ahead of
80
+ * Microsoft standard hits) so custom code is always visible.
81
+ */
82
+ customMatches?: Array<{
83
+ name: string;
84
+ type: string;
85
+ }>;
58
86
  }
59
87
  export declare function tryBridgeSearch(bridge: BridgeClient | undefined, query: string, objectType?: string, maxResults?: number, opts?: BridgeSearchOptions): Promise<ToolResult | null>;
60
88
  export declare function tryBridgeQuery(bridge: BridgeClient | undefined, queryName: string): Promise<ToolResult | null>;
@@ -194,9 +222,14 @@ export declare function bridgeRemoveIndex(bridge: BridgeClient | undefined, tabl
194
222
  export declare function bridgeAddRelation(bridge: BridgeClient | undefined, tableName: string, relationName: string, relatedTable: string, constraints?: Array<{
195
223
  field?: string;
196
224
  relatedField?: string;
197
- }>): Promise<{
225
+ }>, properties?: {
226
+ relationCardinality?: string;
227
+ relatedTableCardinality?: string;
228
+ relationshipType?: string;
229
+ }): Promise<{
198
230
  success: boolean;
199
231
  message: string;
232
+ propertiesWritten?: boolean;
200
233
  } | null>;
201
234
  /**
202
235
  * Removes a relation from a table via the C# bridge.
@@ -13,7 +13,8 @@
13
13
  import * as debouncedRefresh from './debouncedRefresh.js';
14
14
  import { debugLog } from '../utils/logger.js';
15
15
  import { reindentXppSource } from '../utils/xppFormat.js';
16
- import { rankExactFirst, isExactNameMatch } from '../utils/exactMatchRanking.js';
16
+ import { parseXppDeclaration } from '../metadata/xppDeclaration.js';
17
+ import { rankCustomFirst, isExactNameMatch } from '../utils/exactMatchRanking.js';
17
18
  // TABLE
18
19
  const TABLE_METHOD_PAGE_SIZE = 25;
19
20
  export async function tryBridgeTable(bridge, tableName, methodOffset = 0) {
@@ -117,6 +118,38 @@ export async function tryBridgeClass(bridge, className, compact, methodOffset =
117
118
  return null;
118
119
  }
119
120
  }
121
+ /**
122
+ * The one-line signature shown per method in the compact class view.
123
+ *
124
+ * This used to be `source.split('\n')[0]`, which for any method whose body opens
125
+ * with an XML doc comment rendered the METHOD NAME as `/// <summary>`: on
126
+ * Foundation's CustVendVoucher, 14 of the first 15 methods came out that way
127
+ * (L2-datetime-timezone-range run). Every documented platform class was
128
+ * therefore unreadable through the default grounding path, while the sibling
129
+ * `options={members:"names"}` path answered correctly.
130
+ *
131
+ * The declaration is located with the shared X++ parser, which handles
132
+ * parameter lists wrapped across lines; only if that fails do we fall back to
133
+ * the first line that is neither blank, nor a comment, nor an attribute — and
134
+ * finally to the name the metadata itself carries, which is never wrong.
135
+ */
136
+ export function methodSignatureLine(name, source) {
137
+ if (!source)
138
+ return name;
139
+ const decl = parseXppDeclaration(source, name);
140
+ if (decl) {
141
+ const params = decl.parameters
142
+ .map(p => `${p.type} ${p.name}${p.defaultValue ? ` = ${p.defaultValue}` : ''}`)
143
+ .join(', ');
144
+ const mods = decl.modifiers.length ? `${decl.modifiers.join(' ')} ` : '';
145
+ return `${mods}${decl.returnType} ${decl.name}(${params})`;
146
+ }
147
+ const firstCode = source
148
+ .split('\n')
149
+ .map(l => l.trim())
150
+ .find(l => l && !l.startsWith('///') && !l.startsWith('//') && !l.startsWith('/*') && !l.startsWith('*') && !l.startsWith('['));
151
+ return firstCode || name;
152
+ }
120
153
  function formatClass(cls, compact, methodOffset) {
121
154
  const modifiers = [];
122
155
  if (cls.isFinal)
@@ -145,9 +178,7 @@ function formatClass(cls, compact, methodOffset) {
145
178
  out += `)\n\n`;
146
179
  for (const m of visible) {
147
180
  if (compact) {
148
- // Signature-only: extract first line of source for signature
149
- const sig = m.source ? m.source.split('\n')[0].trim() : m.name;
150
- out += `- \`${sig}\`\n`;
181
+ out += `- \`${methodSignatureLine(m.name, m.source)}\`\n`;
151
182
  }
152
183
  else {
153
184
  out += `### ${m.name}\n\n`;
@@ -609,11 +640,27 @@ export async function tryBridgeSearch(bridge, query, objectType, maxResults = 50
609
640
  known.add(`${cand.name.toLowerCase()}${cand.type}`);
610
641
  spliced.push({ ...cand, fromIndex: true });
611
642
  }
612
- const merged = [...spliced, ...bridgeHits];
643
+ const key = (n, t) => `${n.toLowerCase()} ${t}`;
644
+ const exactKeys = new Set(spliced.map(s => key(s.name, s.type)));
645
+ const bridgeKeys = new Set(bridgeHits.map(r => key(r.name, r.type)));
646
+ // Custom/ISV matches truncated out of the Microsoft-dominated bridge window:
647
+ // splice the ones the window missed and flag them so they rank ahead of
648
+ // Microsoft standard hits (see BridgeSearchOptions.customMatches).
649
+ const customKeys = new Set((opts?.customMatches ?? []).map(c => key(c.name, c.type)));
650
+ const customSpliced = (opts?.customMatches ?? [])
651
+ .filter(c => !exactKeys.has(key(c.name, c.type)) && !bridgeKeys.has(key(c.name, c.type)))
652
+ .map(c => ({ name: c.name, type: c.type, fromIndex: true, custom: true }));
653
+ const splicedCustom = customSpliced.length;
654
+ const merged = [
655
+ ...spliced.map(s => ({ ...s })),
656
+ ...customSpliced,
657
+ ...bridgeHits.map(r => ({ name: r.name, type: r.type, custom: customKeys.has(key(r.name, r.type)) })),
658
+ ];
613
659
  if (merged.length === 0)
614
660
  return null;
615
661
  // Exact name matches first — the bridge itself returns provider order (#15).
616
- const ranked = rankExactFirst(query, merged, r => r.name).slice(0, Math.max(maxResults, spliced.length));
662
+ const ranked = rankCustomFirst(query, merged, r => r.name, r => !!r.custom)
663
+ .slice(0, Math.max(maxResults, spliced.length + splicedCustom));
617
664
  let out = `# Search: "${query}"${objectType ? ` (type: ${objectType})` : ''}\n\n`;
618
665
  out += `**Results:** ${ranked.length}\n`;
619
666
  out += `_Source: C# bridge (IMetadataProvider)_\n\n`;
@@ -625,6 +672,10 @@ export async function tryBridgeSearch(bridge, query, objectType, maxResults = 50
625
672
  out += `\n_${spliced.length} exact match(es) came from the SQLite symbol index — ` +
626
673
  `the bridge's result window (maxResults=${maxResults}) did not contain them._\n`;
627
674
  }
675
+ if (splicedCustom > 0) {
676
+ out += `\n_${splicedCustom} custom/ISV-model match(es) were spliced in from the ` +
677
+ `SQLite symbol index and prioritized (bridge window maxResults=${maxResults} had truncated them)._\n`;
678
+ }
628
679
  return { content: [{ type: 'text', text: out }] };
629
680
  }
630
681
  catch (e) {
@@ -1009,6 +1060,9 @@ const BRIDGE_MODIFY_OPS = new Set([
1009
1060
  'add-field', 'modify-field', 'rename-field', 'replace-all-fields', 'remove-field',
1010
1061
  'add-index', 'remove-index',
1011
1062
  'add-relation', 'remove-relation',
1063
+ // No C# op backs these — they are served by a direct-XML writer, but they still
1064
+ // pass through the same modify gate.
1065
+ 'add-delete-action', 'remove-delete-action',
1012
1066
  'add-field-group', 'remove-field-group', 'add-field-to-field-group',
1013
1067
  'modify-property',
1014
1068
  'add-enum-value', 'modify-enum-value', 'remove-enum-value',
@@ -1249,15 +1303,23 @@ export async function bridgeRemoveIndex(bridge, tableName, indexName) {
1249
1303
  /**
1250
1304
  * Adds a relation to a table via the C# bridge.
1251
1305
  */
1252
- export async function bridgeAddRelation(bridge, tableName, relationName, relatedTable, constraints) {
1306
+ export async function bridgeAddRelation(bridge, tableName, relationName, relatedTable, constraints, properties) {
1253
1307
  if (!bridge?.isReady || !bridge.metadataAvailable)
1254
1308
  return null;
1255
1309
  try {
1256
- const result = await bridge.addRelation(tableName, relationName, relatedTable, constraints);
1310
+ const result = await bridge.addRelation(tableName, relationName, relatedTable, constraints, properties);
1311
+ // An older bridge binary silently ignores the extra params; it echoes back only
1312
+ // what it set, so the response says whether the on-disk fallback is still needed.
1313
+ const r = result;
1314
+ const propertiesWritten = typeof r.relationshipType === 'string';
1257
1315
  return {
1258
1316
  success: result.success,
1317
+ propertiesWritten,
1259
1318
  message: result.success
1260
- ? `✅ Relation '${relationName}' added via ${result.api}`
1319
+ ? `✅ Relation '${relationName}' added via ${result.api}` +
1320
+ (propertiesWritten
1321
+ ? ` (Cardinality=${r.cardinality}, RelatedTableCardinality=${r.relatedTableCardinality}, RelationshipType=${r.relationshipType})`
1322
+ : '')
1261
1323
  : `Bridge addRelation returned success=false`,
1262
1324
  };
1263
1325
  }
@@ -193,11 +193,24 @@ export declare class BridgeClient extends EventEmitter {
193
193
  addIndex(tableName: string, indexName: string, fields?: string[], allowDuplicates?: boolean, alternateKey?: boolean): Promise<BridgeWriteResult>;
194
194
  /** Remove an index from a table */
195
195
  removeIndex(tableName: string, indexName: string): Promise<BridgeWriteResult>;
196
- /** Add a relation to a table */
196
+ /**
197
+ * Add a relation to a table.
198
+ *
199
+ * `properties` carries Cardinality / RelatedTableCardinality / RelationshipType —
200
+ * real AxTableRelation properties the bridge now sets through the provider. They
201
+ * used to be dropped on both sides, which is what raised
202
+ * BPErrorTableRelationshipPropertiesCompleteness on a relation reported as added
203
+ * (findings #5 / #35). An invalid value is rejected by the bridge with the list of
204
+ * legal ones rather than silently ignored.
205
+ */
197
206
  addRelation(tableName: string, relationName: string, relatedTable: string, constraints?: Array<{
198
207
  field?: string;
199
208
  relatedField?: string;
200
- }>): Promise<BridgeWriteResult>;
209
+ }>, properties?: {
210
+ relationCardinality?: string;
211
+ relatedTableCardinality?: string;
212
+ relationshipType?: string;
213
+ }): Promise<BridgeWriteResult>;
201
214
  /** Remove a relation from a table */
202
215
  removeRelation(tableName: string, relationName: string): Promise<BridgeWriteResult>;
203
216
  /** Add a field group to a table */