d365fo-mcp 1.8.5 → 1.9.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.
@@ -18,7 +18,7 @@ Call `get_workspace_info()` before doing anything with D365FO objects.
18
18
  |----------|--------|
19
19
  | Call fails | STOP. MCP server not connected. Ask user to start it. |
20
20
  | `⛔ CONFIGURATION PROBLEM` | STOP. Relay message. Wait for user. |
21
- | `✅ Configuration looks valid` | Note model name. Proceed. |
21
+ | No `⛔` in the response | Note the `Model` / `Prefix` lines. Proceed. |
22
22
 
23
23
  ## Terminal Prohibition
24
24
 
@@ -9,6 +9,10 @@
9
9
  * folder, or, when that is unknown, into a staging folder with a README
10
10
  * naming the two destinations.
11
11
  *
12
+ * The README belongs to the staging folder only. The solutions folder is the
13
+ * user's own projects directory, so the direct copy leaves nothing there but
14
+ * `.github\copilot-instructions.md` and says the rest on the console.
15
+ *
12
16
  * The README points at the one .mcp.json `mcpJsonNote()` writes in the data
13
17
  * root rather than copying it: the staging folder sits in that same data
14
18
  * root, so a second copy is only one more file to keep in sync.
@@ -22,7 +26,7 @@ import { dataRoot, paths, repoRoot } from './context.js';
22
26
  import { askConfirm, p } from './ui.js';
23
27
  /** The copy shipped with this installation — package root in both install modes. */
24
28
  const copilotSource = () => resolve(repoRoot, '.github', 'copilot-instructions.md');
25
- function writeCopilotSetupReadme(targetDir, opts) {
29
+ function writeCopilotSetupReadme(targetDir) {
26
30
  const readmePath = resolve(targetDir, 'README.md');
27
31
  const lines = [
28
32
  '# VS setup quick guide',
@@ -35,9 +39,7 @@ function writeCopilotSetupReadme(targetDir, opts) {
35
39
  ` - Generated copy: ${paths.mcpSuggestion}`,
36
40
  '',
37
41
  '2. Copy .github/copilot-instructions.md',
38
- opts.copilotAlreadyPlaced
39
- ? ' - Already placed here: .github\\copilot-instructions.md'
40
- : ' - Destination: a parent folder of your solution directories',
42
+ ' - Destination: a parent folder of your solution directories',
41
43
  ' - Why: provides mandatory D365FO tool-routing and safety rules for Copilot',
42
44
  '',
43
45
  '3. Restart Visual Studio after copying files.',
@@ -64,16 +66,16 @@ export async function maybePrepareCopilotInstructions(solutionsPath) {
64
66
  const targetDir = resolve(target, '.github');
65
67
  fs.mkdirSync(targetDir, { recursive: true });
66
68
  fs.copyFileSync(source, resolve(targetDir, 'copilot-instructions.md'));
67
- writeCopilotSetupReadme(target, { copilotAlreadyPlaced: true });
68
69
  p.log.success(`Prepared: ${resolve(targetDir, 'copilot-instructions.md')}`);
69
- p.log.success(`Prepared: ${resolve(target, 'README.md')}`);
70
+ p.log.info('Still to do: copy .mcp.json to %USERPROFILE%\\.mcp.json (or next to your .sln), then restart Visual Studio.\n' +
71
+ ` Generated copy: ${paths.mcpSuggestion}`);
70
72
  return;
71
73
  }
72
74
  const stageDir = resolve(dataRoot(), 'copilot-setup');
73
75
  const stageGitHubDir = resolve(stageDir, '.github');
74
76
  fs.mkdirSync(stageGitHubDir, { recursive: true });
75
77
  fs.copyFileSync(source, resolve(stageGitHubDir, 'copilot-instructions.md'));
76
- writeCopilotSetupReadme(stageDir, { copilotAlreadyPlaced: false });
78
+ writeCopilotSetupReadme(stageDir);
77
79
  if (wantsDirectCopy && !target) {
78
80
  p.log.warn('Solutions folder is empty, so files were prepared in the local staging folder instead.');
79
81
  }
@@ -8,7 +8,7 @@
8
8
  *
9
9
  * Kept deliberately under 200 lines: the prompt holds only the tool decision
10
10
  * tree and hard prohibitions. Everything that is a rule about CODE lives in
11
- * the queryable knowledge base — get_xpp_knowledge (see the ID table below).
11
+ * the queryable knowledge base — get_knowledge (see the ID table below).
12
12
  */
13
13
  /**
14
14
  * Get the system instructions prompt definition
@@ -8,7 +8,7 @@
8
8
  *
9
9
  * Kept deliberately under 200 lines: the prompt holds only the tool decision
10
10
  * tree and hard prohibitions. Everything that is a rule about CODE lives in
11
- * the queryable knowledge base — get_xpp_knowledge (see the ID table below).
11
+ * the queryable knowledge base — get_knowledge (see the ID table below).
12
12
  */
13
13
  /**
14
14
  * Get the system instructions prompt definition
@@ -117,7 +117,7 @@ You are an AI assistant with access to D365FO MCP tools, assisting with Dynamics
117
117
  - No literal strings in \`Info()\`/\`error()\`/labels — use \`@Model:LabelId\` (reuse via \`labels(action="search")\` first)
118
118
  - Every public/protected member needs a meaningful \`/// <summary>\` (not "MyClass class.")
119
119
 
120
- **For full rules and examples call \`get_xpp_knowledge(id)\` BEFORE generating code:**
120
+ **For full rules and examples call \`get_knowledge(kind="knowledge", topic=<id>)\` BEFORE generating code:**
121
121
 
122
122
  | Knowledge ID | Covers |
123
123
  |---|---|
@@ -20,7 +20,7 @@ export const getWorkspaceInfoTool = {
20
20
  diagnostics: {
21
21
  type: 'boolean',
22
22
  default: false,
23
- description: 'Include verbose diagnostic sections (suffix breakdown, stdio session/handshake dump). Use when debugging client-server connectivity.',
23
+ description: 'Include verbose sections (config sources, suffix, project paths, index scan, stdio handshake). Use when debugging config or connectivity.',
24
24
  },
25
25
  },
26
26
  required: [],
@@ -84,7 +84,7 @@ internal final class ${name}
84
84
  // D365FO Data Entity: ${name}Entity
85
85
  // ══════════════════════════════════════════════════════════════════
86
86
  // Data entities are AxDataEntityView XML objects (NOT X++ classes).
87
- // Use create_d365fo_file(objectType="view") or the VS designer.
87
+ // Use d365fo_file(action="create", objectType="view") or the VS designer.
88
88
  //
89
89
  // Key properties to set in XML:
90
90
  // PublicEntityName: "${name}" (OData singular name)
@@ -107,8 +107,8 @@ internal final class ${name}
107
107
  //
108
108
  // Workflow:
109
109
  // 1. get_object_info(objectType="data-entity", name="similar entity") → study structure
110
- // 2. generate_d365fo_xml(objectType="data-entity", ...) → preview XML
111
- // 3. create_d365fo_file(objectType="view", ...) → create file
110
+ // 2. generate_object(mode="scaffold", objectType="data-entity", ...) → preview XML
111
+ // 3. d365fo_file(action="create", objectType="view", ...) → create file
112
112
  // 4. After deployment: refresh entity list in Data Management workspace
113
113
  `,
114
114
  'batch-job': (name) => `
@@ -646,7 +646,7 @@ function menuItemXmlTemplate(name, itemType, targetObject) {
646
646
  function ssrsReportFullTemplate(name) {
647
647
  return `// ══════════════════════════════════════════════════════════════════
648
648
  // SSRS Report: ${name}
649
- // 5 objects required (use create_d365fo_file for each):
649
+ // 5 objects required (use d365fo_file(action="create") for each):
650
650
  // 1. ${name}TmpTable — TempDB table (objectType="table", tableType="TempDB")
651
651
  // 2. ${name}Contract — DataContract class (below)
652
652
  // 3. ${name}DP — Data Provider class (below)
@@ -1053,14 +1053,14 @@ public class ${name}Controller extends MenuFunction
1053
1053
  function dataEntityStagingTemplate(name) {
1054
1054
  return `// ══════════════════════════════════════════════════════════════════════════
1055
1055
  // Data Entity with Staging Table: ${name}
1056
- // 3 objects required (use create_d365fo_file for each):
1056
+ // 3 objects required (use d365fo_file(action="create") for each):
1057
1057
  // 1. ${name}StagingTable — TempDB staging table
1058
1058
  // 2. ${name}Entity — Data entity (AxDataEntityView)
1059
1059
  // 3. ${name}EntityService — Optional: AIF service class
1060
1060
  // ══════════════════════════════════════════════════════════════════════════
1061
1061
 
1062
1062
  // ── Object 1: Staging table ${name}Staging ─────────────────────────────────
1063
- // create_d365fo_file(objectType="table", objectName="${name}Staging", xmlContent=...)
1063
+ // d365fo_file(action="create", objectType="table", objectName="${name}Staging", xmlContent=...)
1064
1064
  // Set: TableType=TempDB (NOT RegularTable), TableGroup=Main
1065
1065
  // Fields mirror the entity's public fields exactly (same names, same EDTs)
1066
1066
 
@@ -1661,7 +1661,7 @@ public class ${name}Service
1661
1661
  }
1662
1662
  }
1663
1663
 
1664
- // ── 3. AOT objects (create via create_d365fo_file) ──────────────────────
1664
+ // ── 3. AOT objects (create via d365fo_file(action="create")) ────────────
1665
1665
  // Verify the result afterwards with get_object_info(objectType="service", name="${name}Service").
1666
1666
  // a) AxService XML (real schema: ServiceOperations / AxServiceOperation / Method):
1667
1667
  // <AxService xmlns:i="http://www.w3.org/2001/XMLSchema-instance">
@@ -1822,7 +1822,11 @@ export async function codeGenTool(request) {
1822
1822
  // Extension pattern — args.name is the BASE element; prefix becomes the infix
1823
1823
  // Grounding enforcement: extension patterns require proof that the AI looked at the
1824
1824
  // real codebase (via prepare_change) before generating extension code.
1825
- const groundingError = enforceGrounding(args.groundingToken, `generate_code(pattern="${args.pattern}", name="${args.name}")`, args.name);
1825
+ const groundingError = enforceGrounding(args.groundingToken,
1826
+ // The MCP-visible name, not the internal one: this string is echoed back
1827
+ // as the call the agent must repeat with a token, and `generate_code`
1828
+ // has not been a registered tool since the mega-tool consolidation.
1829
+ `generate_object(mode="pattern", pattern="${args.pattern}", name="${args.name}")`, args.name);
1826
1830
  if (groundingError)
1827
1831
  return groundingError;
1828
1832
  // ── form-datasource-extension / form-control-extension (3-param templates) ──
@@ -1924,20 +1928,18 @@ export async function codeGenTool(request) {
1924
1928
  text: `Generated ${args.pattern} template for "${displayName}":\n\n` +
1925
1929
  `\`\`\`xpp${code}\n\`\`\`\n\n` +
1926
1930
  `---\n\n` +
1927
- `${namingNote}\n\n` +
1931
+ namingNote +
1932
+ // Only the CoC signature rule earns a line here: guessing static vs
1933
+ // instance produces code that fails to compile. The rest was a menu
1934
+ // of optional follow-ups — and it named tools that do not exist
1935
+ // (`find_coc_extensions`) with argument forms analyze_code rejects
1936
+ // (positional, not `className`/`methodName`), so following it cost a
1937
+ // failed call before the agent could get anything useful.
1928
1938
  (args.pattern === 'class-extension'
1929
- ? `💡 **Next Steps (class-extension CoC workflow):**\n\n` +
1930
- `1. 🚨 Use \`get_method(include="signature", "${displayName}", "<methodName>")\` — **REQUIRED** to get the exact signature (static vs instance, return type, parameters) before writing any CoC method\n` +
1931
- `2. Use \`find_coc_extensions("${displayName}", "<methodName>")\` - See existing CoC wrappers for reference\n` +
1932
- `3. ✅ Use \`analyze_code(mode="implementations", "${displayName}", "<methodName>")\` - Get real implementation examples\n` +
1933
- `4. ✅ Use \`analyze_code(mode="api-usage", "<ClassName>")\` - See how to use D365FO APIs correctly\n\n` +
1934
- `⚠️ Never guess static vs instance — always use get_method(include="signature") first.`
1935
- : `💡 **Next Steps for Better Code Quality:**\n\n` +
1936
- `1. ✅ Use \`analyze_code(mode="patterns", "<scenario>")\` - Learn what D365FO classes are commonly used together\n` +
1937
- `2. ✅ Use \`analyze_code(mode="implementations", "${displayName}", "<methodName>")\` - Get real implementation examples\n` +
1938
- `3. ✅ Use \`analyze_code(mode="completeness", "${displayName}")\` - Check for missing common methods\n` +
1939
- `4. ✅ Use \`analyze_code(mode="api-usage", "<ClassName>")\` - See how to use D365FO APIs correctly\n\n` +
1940
- `These tools provide patterns from the actual codebase, not generic templates.`),
1939
+ ? `\n\n⚠️ Before writing any CoC method call \`get_method(include="signature", className="${displayName}", methodName="<methodName>")\` — ` +
1940
+ `never guess static vs instance, the return type or the parameter list. ` +
1941
+ `Existing wrappers: \`extension_info(mode="coc", target="${displayName}")\`.`
1942
+ : ``),
1941
1943
  },
1942
1944
  ],
1943
1945
  };
@@ -4712,16 +4712,13 @@ export async function handleCreateD365File(request, context) {
4712
4712
  `\nUntil resolved, add the file manually in Visual Studio: right-click project → Add Existing Item → ${normalizedFullPath}\n`;
4713
4713
  }
4714
4714
  }
4715
- // Build success message
4715
+ // Only the step the AGENT can take. "Reload the project in VS / refresh the
4716
+ // AOT" is a human's UI chore, repeated on every object of a feature; when it
4717
+ // matters (addToProject failed, no projectPath) `projectMessage` above
4718
+ // already says so, in that specific case.
4716
4719
  const nextSteps = args.addToProject
4717
- ? `Next steps:\n` +
4718
- `1. Reload project in Visual Studio (or close/reopen solution)\n` +
4719
- `2. Build the project to synchronize the object\n` +
4720
- `3. Refresh AOT in Visual Studio to see the new object\n`
4721
- : `Next steps:\n` +
4722
- `1. Add the file to your Visual Studio project (.rnrproj)\n` +
4723
- `2. Build the project to synchronize the object\n` +
4724
- `3. Refresh AOT in Visual Studio to see the new object\n`;
4720
+ ? `Next: build_d365fo_project to synchronize the object.\n`
4721
+ : `Next: add the file to your .rnrproj, then build_d365fo_project to synchronize the object.\n`;
4725
4722
  // Record the freshly-created file for non-git undo (see the bridge paths above).
4726
4723
  if (!fileExisted) {
4727
4724
  recordCreatedArtifact({
@@ -46,9 +46,9 @@ const STRATEGY_RULES = [
46
46
  { mechanism: 'Form data source validateWrite()', when: 'Validation is UI-specific and should NOT apply to service/entity imports' },
47
47
  ],
48
48
  nextSteps: [
49
- 'analyze_extension_points("TableName") — check available events and existing extensions',
50
- 'find_event_handlers("TableName") — see if someone already handles the same event',
51
- 'generate_code(pattern="event-handler") — generate the handler skeleton',
49
+ 'extension_info(mode="points", target="TableName") — available events and existing extensions',
50
+ 'extension_info(mode="events", target="TableName") — see if someone already handles the same event',
51
+ 'generate_object(mode="pattern", pattern="event-handler", name="TableName") — handler skeleton',
52
52
  ],
53
53
  antiPatterns: [
54
54
  { wrong: 'Business Event', why: 'Business Events are for outbound notifications, not validation logic' },
@@ -73,8 +73,8 @@ const STRATEGY_RULES = [
73
73
  { mechanism: 'Form data source initValue()', when: 'Default depends on form context (e.g. header record of a lines form)' },
74
74
  ],
75
75
  nextSteps: [
76
- 'analyze_extension_points("TableName") — check initValue availability',
77
- 'get_method(include="signature", "TableName", "initValue", includeCocTemplate: true) — get CoC template',
76
+ 'extension_info(mode="points", target="TableName") — check initValue availability',
77
+ 'get_method(include="signature", className="TableName", methodName="initValue") — exact signature for the CoC wrapper',
78
78
  ],
79
79
  antiPatterns: [
80
80
  { wrong: 'Overriding insert()', why: 'insert() is for persistence — defaults belong in initValue()' },
@@ -110,9 +110,9 @@ const STRATEGY_RULES = [
110
110
  { mechanism: 'CoC on table.modifiedFieldValue()', when: 'You also need the previous value to decide what to do' },
111
111
  ],
112
112
  nextSteps: [
113
- 'analyze_extension_points("TableName") — confirm modifiedField is CoC-eligible and see existing extensions',
114
- 'get_method(include="signature", "TableName", "modifiedField", includeCocTemplate: true) — get the CoC skeleton',
115
- 'find_coc_extensions("TableName", "modifiedField") — check for existing wrappers',
113
+ 'extension_info(mode="points", target="TableName") — confirm modifiedField is CoC-eligible and see existing extensions',
114
+ 'get_method(include="signature", className="TableName", methodName="modifiedField") — exact signature for the CoC wrapper',
115
+ 'extension_info(mode="coc", target="TableName", method="modifiedField") — check for existing wrappers',
116
116
  ],
117
117
  antiPatterns: [
118
118
  { wrong: 'CoC on initValue()', why: 'initValue fires only at record creation — it will NOT run when the user later changes the field' },
@@ -141,9 +141,9 @@ const STRATEGY_RULES = [
141
141
  { mechanism: 'Replaceable method', when: 'Method is marked [Replaceable] — you can fully replace it (rare in standard app)' },
142
142
  ],
143
143
  nextSteps: [
144
- 'analyze_extension_points("ClassName") — see CoC-eligible methods and delegates',
145
- 'get_method(include="signature", "ClassName", "methodName", includeCocTemplate: true) — get exact CoC skeleton',
146
- 'find_coc_extensions("ClassName", "methodName") — check for existing CoC wrappers',
144
+ 'extension_info(mode="points", target="ClassName") — see CoC-eligible methods and delegates',
145
+ 'get_method(include="signature", className="ClassName", methodName="methodName") — exact signature for the CoC wrapper',
146
+ 'extension_info(mode="coc", target="ClassName", method="methodName") — check for existing CoC wrappers',
147
147
  ],
148
148
  antiPatterns: [
149
149
  { wrong: 'Copy-paste the entire class', why: 'Over-layering defeats the purpose of extensions and blocks upgrades' },
@@ -172,8 +172,8 @@ const STRATEGY_RULES = [
172
172
  { mechanism: 'Dual-write', when: 'Real-time bidirectional sync with Dataverse is needed' },
173
173
  ],
174
174
  nextSteps: [
175
- 'get_knowledge(kind="knowledge", "business events") — learn the pattern',
176
- 'generate_code(pattern="business-event", name="MyEvent") — generate skeleton',
175
+ 'get_knowledge(kind="knowledge", topic="business events") — learn the pattern',
176
+ 'generate_object(mode="pattern", pattern="business-event", name="MyEvent") — generate skeleton',
177
177
  ],
178
178
  antiPatterns: [
179
179
  { wrong: 'CoC calling HttpClient', why: 'Synchronous HTTP in a transaction blocks the user and risks timeout/rollback' },
@@ -203,9 +203,9 @@ const STRATEGY_RULES = [
203
203
  { mechanism: 'Composite entity', when: 'Header + lines structure needs to be imported as a document' },
204
204
  ],
205
205
  nextSteps: [
206
- 'get_knowledge(kind="knowledge", "data-management-framework") — learn DMF patterns',
207
- 'search("MyTable", "data-entity") — check if an entity already exists',
208
- 'generate_code(pattern="data-entity", name="MyEntity") — generate entity skeleton',
206
+ 'get_knowledge(kind="knowledge", topic="data-management-framework") — learn DMF patterns',
207
+ 'search(query="MyTable", type="data-entity") — check if an entity already exists',
208
+ 'generate_object(mode="pattern", pattern="data-entity", name="MyEntity") — generate entity skeleton',
209
209
  ],
210
210
  antiPatterns: [
211
211
  { wrong: 'Direct table insert via custom endpoint', why: 'Bypasses validation, number sequences, and event handlers' },
@@ -234,7 +234,7 @@ const STRATEGY_RULES = [
234
234
  ],
235
235
  nextSteps: [
236
236
  'get_object_info(objectType="form", name="FormName", options={searchControl:"General"}) — find exact control names and hierarchy',
237
- 'analyze_extension_points("FormName") — check form extension points',
237
+ 'extension_info(mode="points", target="FormName", objectType="form") — check form extension points',
238
238
  'd365fo_file(action="create", objectType="form-extension") — create the extension',
239
239
  ],
240
240
  antiPatterns: [
@@ -265,8 +265,8 @@ const STRATEGY_RULES = [
265
265
  ],
266
266
  nextSteps: [
267
267
  'get_object_info(objectType="report", name="ReportName") — inspect existing report structure',
268
- 'get_knowledge(kind="knowledge", "ssrs-reports") — patterns for SSRS',
269
- 'generate_smart(objectType="report", name="MyReport") — generate full SSRS stack',
268
+ 'get_knowledge(kind="knowledge", topic="ssrs-reports") — patterns for SSRS',
269
+ 'generate_object(mode="scaffold", objectType="report", name="MyReport") — generate full SSRS stack',
270
270
  ],
271
271
  antiPatterns: [
272
272
  { wrong: 'Business Event for document delivery', why: 'Business Events send notifications, not formatted documents' },
@@ -290,8 +290,8 @@ const STRATEGY_RULES = [
290
290
  { mechanism: 'Custom counter table', when: 'Simple auto-increment without legal entity scope or configurable format (rare — prefer the framework)' },
291
291
  ],
292
292
  nextSteps: [
293
- 'get_knowledge(kind="knowledge", "number-sequences") — full pattern reference',
294
- 'generate_code(pattern="number-seq-handler") — generate skeleton',
293
+ 'get_knowledge(kind="knowledge", topic="number-sequences") — full pattern reference',
294
+ 'generate_object(mode="pattern", pattern="number-seq-handler", name="MyModule") — generate skeleton',
295
295
  ],
296
296
  antiPatterns: [
297
297
  { wrong: 'Identity column / RecId as business number', why: 'RecId is internal — users need formatted, gapless (or configurable) business numbers' },
@@ -318,8 +318,8 @@ const STRATEGY_RULES = [
318
318
  { mechanism: 'Table permission framework override', when: 'Granting DML access without a menu item entry point (rare)' },
319
319
  ],
320
320
  nextSteps: [
321
- 'get_knowledge(kind="knowledge", "security-privileges-duties") — security pattern reference',
322
- 'security_info(mode="coverage", "ObjectName") — check existing security chain',
321
+ 'get_knowledge(kind="knowledge", topic="security-privileges-duties") — security pattern reference',
322
+ 'security_info(mode="coverage", objectName="ObjectName") — check existing security chain',
323
323
  'd365fo_file(action="create", objectType="security-privilege") — create privilege',
324
324
  ],
325
325
  antiPatterns: [
@@ -346,9 +346,9 @@ const STRATEGY_RULES = [
346
346
  { mechanism: 'Business Event + external processor', when: 'Processing should happen outside D365FO (e.g. Azure Function)' },
347
347
  ],
348
348
  nextSteps: [
349
- 'get_knowledge(kind="knowledge", "sysoperation") — SysOperation patterns',
350
- 'generate_code(pattern="sysoperation", name="MyProcess") — generate SysOperation skeleton',
351
- 'generate_code(pattern="batch-job", name="MyBatch") — generate RunBaseBatch skeleton',
349
+ 'get_knowledge(kind="knowledge", topic="sysoperation") — SysOperation patterns',
350
+ 'generate_object(mode="pattern", pattern="sysoperation", name="MyProcess") — generate SysOperation skeleton',
351
+ 'generate_object(mode="pattern", pattern="batch-job", name="MyBatch") — generate RunBaseBatch skeleton',
352
352
  ],
353
353
  antiPatterns: [
354
354
  { wrong: 'Thread.Sleep / while-polling in batch', why: 'Use batch recurrence and alerts — polling wastes AOS resources' },
@@ -24,8 +24,15 @@
24
24
  */
25
25
  export declare function modelWritesLandIn(anchorModel: string | null, activeModel: string | null): string | null;
26
26
  export interface PrefixDiagnostics {
27
- /** Lines for the "## Prefix Configuration" section, blank line included. */
27
+ /**
28
+ * Compact default: one `Prefix : …` line, plus a note only when something is
29
+ * actually off (a switch is in effect, or the resolved prefix contradicts the
30
+ * configuration). Every call of get_workspace_info pays for these tokens, so
31
+ * the confirmations that only restate the value stay in `verboseLines`.
32
+ */
28
33
  lines: string[];
34
+ /** Full "## Prefix Configuration" section — diagnostics=true only. */
35
+ verboseLines: string[];
29
36
  /** The prefix a write would apply — for the Extension Naming samples. */
30
37
  effectivePrefix: string;
31
38
  }
@@ -67,23 +67,39 @@ export function buildPrefixDiagnostics(writeModel, readModel) {
67
67
  const disagrees = !!learned?.regular && !!extensionPrefixEnv &&
68
68
  bare(learned.regular) !== bare(extensionPrefixEnv);
69
69
  const switched = !!writeModel && !!readModel && !sameModel(writeModel, readModel);
70
- const lines = [
70
+ const switchNote = `ℹ️ This is the prefix for WRITES, which are anchored to "${writeModel}". "${readModel}" is ` +
71
+ `merely the active project and its own prefix may differ — see the project-switch note below.`;
72
+ const disagreeNote = `⚠️ The model's own objects use "${learned?.regular}", which overrides EXTENSION_PREFIX="${extensionPrefixEnv}" — new objects will be named "${effectivePrefix}…". If that is wrong, the model's existing objects are the thing to check; set EXTENSION_PREFIX_SOURCE=config to pin the configured value instead.`;
73
+ const notConfiguredNote = `⚠️ EXTENSION_PREFIX is not set in the server environment. The model name "${writeModel}" will be used as prefix. Add EXTENSION_PREFIX=MY (or your ISV prefix) to the .env file and restart the server.`;
74
+ // Compact: the value, its origin, and a warning only when there is one. The
75
+ // warnings keep the fix but drop the explanation of it — the resolved prefix
76
+ // is one line above, so what is wrong is already visible.
77
+ const lines = [`Prefix : ${effectivePrefix || '(none)'} (${source})`];
78
+ if (switched)
79
+ lines.push(switchNote);
80
+ if (disagrees) {
81
+ lines.push(`⚠️ The model's own objects use "${learned?.regular}", overriding EXTENSION_PREFIX="${extensionPrefixEnv}". ` +
82
+ `To pin the configured value instead: EXTENSION_PREFIX_SOURCE=config.`);
83
+ }
84
+ else if (!learned?.regular && !extensionPrefixEnv) {
85
+ lines.push(`⚠️ EXTENSION_PREFIX is not set — the model name is being used as the prefix. ` +
86
+ `Add EXTENSION_PREFIX=MY (your ISV prefix) to .env and restart the server.`);
87
+ }
88
+ const verboseLines = [
71
89
  `## Prefix Configuration`,
72
90
  ``,
73
91
  `EXTENSION_PREFIX: ${extensionPrefixEnv ?? '(not set — falling back to model name)'}`,
74
92
  `Effective prefix: ${effectivePrefix || '(none)'} (source: ${source})`,
75
93
  ];
76
- if (switched) {
77
- lines.push(`ℹ️ This is the prefix for WRITES, which are anchored to "${writeModel}". "${readModel}" is ` +
78
- `merely the active project and its own prefix may differ — see the project-switch note below.`);
79
- }
80
- lines.push(disagrees
81
- ? `⚠️ The model's own objects use "${learned.regular}", which overrides EXTENSION_PREFIX="${extensionPrefixEnv}" — new objects will be named "${effectivePrefix}…". If that is wrong, the model's existing objects are the thing to check; set EXTENSION_PREFIX_SOURCE=config to pin the configured value instead.`
94
+ if (switched)
95
+ verboseLines.push(switchNote);
96
+ verboseLines.push(disagrees
97
+ ? disagreeNote
82
98
  : learned?.regular
83
99
  ? `✅ Prefix "${effectivePrefix}" comes from the objects model "${writeModel}" already contains.`
84
100
  : extensionPrefixEnv
85
101
  ? `✅ EXTENSION_PREFIX is set — all new objects will use prefix "${effectivePrefix}".`
86
- : `⚠️ EXTENSION_PREFIX is not set in the server environment. The model name "${writeModel}" will be used as prefix. Add EXTENSION_PREFIX=MY (or your ISV prefix) to the .env file and restart the server.`, ``);
87
- return { lines, effectivePrefix };
102
+ : notConfiguredNote, ``);
103
+ return { lines, verboseLines, effectivePrefix };
88
104
  }
89
105
  //# sourceMappingURL=prefixDiagnostics.js.map
@@ -237,7 +237,9 @@ async function performHybridSearch(args, context) {
237
237
  output += `\n• ${tip.tip}${toolHint}`;
238
238
  });
239
239
  }
240
- output += `\n\n💡 **Workspace-aware search** includes both your local project files and D365FO external metadata.`;
240
+ // No trailing "this search is workspace-aware" note: the 🔹/📦 marker on every
241
+ // row already says which side a hit came from, and search is the most-called
242
+ // tool — a fixed footer here is paid for on every call for nothing actionable.
241
243
  return {
242
244
  content: [
243
245
  {
@@ -32,9 +32,11 @@ import { prepareTool } from './prepare.js';
32
32
  import { recordToolStart, startMetricsLogging, recordCallSequence } from '../utils/toolMetrics.js';
33
33
  import { DEDUP_EXCLUDED_TOOLS, DEDUP_TTL_MS, dedupKey, getDedupedResult, storeDedupResult, appendNote, getInFlight, registerInFlight, clearInFlight, } from '../utils/callDedup.js';
34
34
  import { checkIndexStaleness } from '../utils/indexStaleness.js';
35
- import { buildContextSnapshot, renderContextSnapshotSection } from '../workspace/contextSnapshot.js';
35
+ import { buildContextSnapshot, renderContextSnapshotSection, renderContextSnapshotCompact, } from '../workspace/contextSnapshot.js';
36
36
  import * as nodePath from 'path';
37
37
  import { buildProgressMessage } from '../utils/toolProgressMessage.js';
38
+ /** Models named inline by the compact project list before it summarises the rest. */
39
+ const PROJECT_NAMES_SHOWN = 12;
38
40
  /**
39
41
  * Extract workspace path from GitHub Copilot _meta.
40
42
  * HTTP requests must not overwrite the shared runtimeContext (AsyncLocalStorage
@@ -86,6 +88,10 @@ const TOOL_CAP_SIZES = {
86
88
  build_d365fo_project: 'uncapped', // compiler errors can appear late in long logs
87
89
  security_info: 8000,
88
90
  extension_info: 6000,
91
+ // Default output is ~1 KB. The higher cap exists for diagnostics=true, whose
92
+ // whole point is the full dump — truncating that at 5000 hid the stdio
93
+ // handshake section behind the project table.
94
+ get_workspace_info: 20000,
89
95
  default: 5000,
90
96
  };
91
97
  function getCapForTool(toolName) {
@@ -304,7 +310,7 @@ export function registerToolHandler(server, context) {
304
310
  // from — after a project switch those are different models (see
305
311
  // buildPrefixDiagnostics and ConfigManager.getWriteAnchorModel).
306
312
  const writeModel = modelWritesLandIn(configManager.getWriteAnchorModel() ?? modelName, modelName);
307
- const { lines: prefixLines, effectivePrefix } = buildPrefixDiagnostics(writeModel, modelName);
313
+ const { lines: prefixLines, verboseLines: prefixVerboseLines, effectivePrefix, } = buildPrefixDiagnostics(writeModel, modelName);
308
314
  const objectSuffixEnv = process.env.EXTENSION_SUFFIX?.trim() || null;
309
315
  const effectiveSuffix = getObjectSuffix();
310
316
  const PLACEHOLDER_NAMES = new Set([
@@ -327,42 +333,40 @@ export function registerToolHandler(server, context) {
327
333
  const effectiveWriteSource = customPackagesPath ? customPackagesSource : packageSource;
328
334
  // MS framework path shown in diagnostics; omitted for single-root traditional setups.
329
335
  const msFrameworkPath = frameworkDirectory ?? (!customPackagesPath ? null : packagePath);
330
- // Verbose diagnostic sections cost tokens on every call opt-in only.
336
+ // get_workspace_info is called at the start of every session, so every
337
+ // line here is paid for on a cold context. The default output carries
338
+ // only what changes what the agent does next — identity, where writes
339
+ // land, the naming it must apply, and anything that is actually wrong.
340
+ // Sources, confirmations and the per-project path table are diagnostics.
331
341
  const diagnostics = args.diagnostics === true;
332
- const lines = [
333
- `## D365FO Workspace Configuration`,
334
- ``,
335
- `Model name : ${modelName ?? '(not configured)'} (source: ${modelSource})`,
336
- `Custom write path: ${effectiveWritePath ?? '(not configured)'} (custom metadata, source: ${effectiveWriteSource})`,
337
- `Framework dir : ${msFrameworkPath ?? '(not applicable — single-root setup)'} (Microsoft metadata, read-only)`,
338
- `Project path : ${projectPath ?? '(not detected)'} (source: ${projectSource})`,
339
- `Env type : ${envType}`,
340
- ``,
341
- ...prefixLines,
342
- ];
343
- // A switch moves which project is ACTIVE — nothing else. Reads never
344
- // needed it (they span every model regardless) and writes stay anchored
345
- // to the model the workspace resolved on its own, so switching cannot be
346
- // used to reach an object the cross-model guard refused. Say both here,
347
- // before anything is written, so the switch is not mistaken for access.
348
- const toolSwitch = configManager.getToolProjectSwitch();
349
- if (toolSwitch) {
350
- lines.push(`## ⚠️ Project switched — writes are NOT switched`, ``, `"${toolSwitch.forcedModel}" is now the ACTIVE project. This did not change what you ` +
351
- `can read: get_object_info, search and find_references span every model, switched or ` +
352
- `not, so a switch is never needed to look at another model's code. Writes stay ` +
353
- `anchored to "${toolSwitch.anchorModel}" — the model the open workspace targets — and ` +
354
- `a create/modify into "${toolSwitch.forcedModel}" will be refused.`, `Tell the user the model they asked about is owned by "${toolSwitch.forcedModel}" and let ` +
355
- `THEM decide: extend it from "${toolSwitch.anchorModel}", or allow the write by adding ` +
356
- `D365FO_CROSS_MODEL_WRITE_MODELS=${toolSwitch.forcedModel} to the server's .env — that ` +
357
- `applies to the next attempt, no restart. Do not decide this on your own.`, ``);
358
- }
342
+ const lines = diagnostics
343
+ ? [
344
+ `## D365FO Workspace Configuration`,
345
+ ``,
346
+ `Model name : ${modelName ?? '(not configured)'} (source: ${modelSource})`,
347
+ `Custom write path: ${effectiveWritePath ?? '(not configured)'} (custom metadata, source: ${effectiveWriteSource})`,
348
+ `Framework dir : ${msFrameworkPath ?? '(not applicable — single-root setup)'} (Microsoft metadata, read-only)`,
349
+ `Project path : ${projectPath ?? '(not detected)'} (source: ${projectSource})`,
350
+ `Env type : ${envType}`,
351
+ ``,
352
+ ...prefixVerboseLines,
353
+ ]
354
+ : [
355
+ `## D365FO Workspace`,
356
+ ``,
357
+ `Model : ${modelName ?? '(not configured)'} (${modelSource})`,
358
+ ...prefixLines,
359
+ `Write path : ${effectiveWritePath ?? '(not configured)'}`,
360
+ `Project : ${projectPath ?? '(not detected)'}`,
361
+ `Env : ${envType}`,
362
+ ];
359
363
  if (diagnostics) {
360
364
  lines.push(`## Suffix Configuration`, ``, `EXTENSION_SUFFIX: ${objectSuffixEnv ?? '(not set)'}`, `Effective suffix: ${effectiveSuffix || '(none)'}`, effectiveSuffix
361
365
  ? `✅ EXTENSION_SUFFIX is set — new objects will have suffix "${effectiveSuffix}" appended (e.g. MyTable${effectiveSuffix}).`
362
366
  : `ℹ️ EXTENSION_SUFFIX is not set. No suffix will be applied. This is normal — most projects use prefixes only.`, ``);
363
367
  }
364
368
  else if (effectiveSuffix) {
365
- lines.push(`Suffix : "${effectiveSuffix}" appended to new objects (EXTENSION_SUFFIX)`, ``);
369
+ lines.push(`Suffix : "${effectiveSuffix}" appended to new objects (EXTENSION_SUFFIX)`);
366
370
  }
367
371
  // Extension naming: prefix is the infix unless EXTENSION_NAMING_STYLE="model-name"
368
372
  // (embeds the model name instead, VS default). Tool always normalises the token —
@@ -379,40 +383,48 @@ export function registerToolHandler(server, context) {
379
383
  const sampleElemExt = extNamingStyle === 'model-name' && writeModel
380
384
  ? `CustTable.${writeModel}`
381
385
  : `CustTable.${extInfix}Extension`;
382
- lines.push(`## Extension Naming`, ``, `EXTENSION_NAMING_STYLE: ${process.env.EXTENSION_NAMING_STYLE?.trim() || '(not set → "prefix")'}`, extNamingStyle === 'model-name'
383
- ? `✅ model-name style extension token is the MODEL NAME (Visual Studio default).`
384
- : `ℹ️ prefix style (default) — extension token is the EXTENSION_PREFIX infix.`, ` • Extension class → ${sampleClassExt}`, ` • Element extension → ${sampleElemExt}`, ` ⚠️ Pass the BASE object name (e.g. "CustTable") to d365fo_file(action="create") and let the tool apply the token — any infix you embed will be normalised to the above.`, ``);
385
- if (isPlaceholder) {
386
- const rawDetectedModel = await configManager.getRawAutoDetectedModelName();
387
- const detectedHint = rawDetectedModel
388
- ? `> ✅ Auto-detected from .rnrproj: **${rawDetectedModel}**\n` +
389
- `> Update your .mcp.json: set \`modelName\` to \`"${rawDetectedModel}"\``
390
- : `> ⚠️ No .rnrproj was found — make sure the MCP server is running in the right directory.`;
391
- lines.push(`⛔ CONFIGURATION PROBLEM — model name "${modelName}" is a placeholder, not a real D365FO model.`, ``, `**YOU MUST STOP** and tell the user:`, `> The configured model name "${modelName}" is a placeholder.`, detectedHint, `>`, `> Please check that:`, `> 1. The MCP server is running in the correct workspace directory`, `> 2. The .mcp.json / mcp.json file has the correct modelName`, `> 3. The projectPath points to a valid .rnrproj file`, `>`, `> Do you want to fix the configuration first, or continue with built-in tools (limited functionality)?`);
392
- }
393
- else if (isStandardMsModel) {
394
- const allProj = configManager.getAllDetectedProjects();
395
- const customCandidates = allProj.filter(p => isCustomModel(p.modelName));
396
- const hint = customCandidates.length > 0
397
- ? `Available custom models: ${customCandidates.map(p => p.modelName).join(', ')}\n` +
398
- `Switch with: get_workspace_info(projectName="<model>")`
399
- : `No custom models found under D365FO_SOLUTIONS_PATH. Check your project configuration.`;
400
- lines.push(`⛔ CONFIGURATION PROBLEM — model name "${modelName}" is a Microsoft standard/demo model, not a custom model.`, ``, `**YOU MUST STOP** and tell the user:`, `> The auto-detected model "${modelName}" is a Microsoft standard model.`, `> This usually happens when a new VS project was created and the default model`, `> in the project wizard ("FleetManagement") was not changed to the correct custom model.`, `>`, `> How to fix:`, `> 1. In Visual Studio, open the .rnrproj file and change <Model>FleetManagement</Model>`, `> to the correct model name (e.g. <Model>ContosoCore</Model>).`, `> 2. OR explicitly switch to a known project:`, `> ${hint}`, `> 3. OR add the correct modelName to .mcp.json.`);
386
+ if (diagnostics) {
387
+ lines.push(`## Extension Naming`, ``, `EXTENSION_NAMING_STYLE: ${process.env.EXTENSION_NAMING_STYLE?.trim() || '(not set "prefix")'}`, extNamingStyle === 'model-name'
388
+ ? `✅ model-name style — extension token is the MODEL NAME (Visual Studio default).`
389
+ : `ℹ️ prefix style (default) — extension token is the EXTENSION_PREFIX infix.`, ` • Extension class → ${sampleClassExt}`, ` • Element extension → ${sampleElemExt}`, ` ⚠️ Pass the BASE object name (e.g. "CustTable") to d365fo_file(action="create") and let the tool apply the token — any infix you embed will be normalised to the above.`, ``);
401
390
  }
402
391
  else {
403
- lines.push(`✅ Configuration looks valid. Proceed with D365FO operations using model "${modelName}".`);
392
+ // The samples ARE the instruction: the style name and the token rule
393
+ // add nothing once the two produced names are in front of the agent.
394
+ lines.push(`Extensions : ${sampleClassExt} · ${sampleElemExt} ` +
395
+ `(pass the BASE name to d365fo_file(create) — the tool applies the token)`);
404
396
  }
405
397
  const allProjects = configManager.getAllDetectedProjects();
406
398
  if (allProjects.length > 1) {
407
- lines.push(``);
408
- lines.push(`## Available Projects`);
409
- lines.push(``);
410
- for (const p of allProjects) {
411
- const active = p.projectPath === projectPath ? '▶ ' : ' ';
412
- lines.push(`${active}${p.modelName.padEnd(40)} ${p.projectPath}`);
399
+ if (diagnostics) {
400
+ lines.push(``);
401
+ lines.push(`## Available Projects`);
402
+ lines.push(``);
403
+ for (const p of allProjects) {
404
+ const active = p.projectPath === projectPath ? '▶ ' : ' ';
405
+ lines.push(`${active}${p.modelName.padEnd(40)} ${p.projectPath}`);
406
+ }
407
+ lines.push(``);
408
+ lines.push(`To switch project: call get_workspace_info with projectName = "<ModelName>"`);
409
+ }
410
+ else {
411
+ // Names only: a switch is by projectName, so the paths are dead
412
+ // weight — and one solution can hold dozens of them. Duplicated
413
+ // model names (several projects, one model) collapse to one entry.
414
+ const seen = new Set();
415
+ const names = [];
416
+ for (const p of allProjects) {
417
+ const key = p.modelName.toLowerCase();
418
+ if (seen.has(key))
419
+ continue;
420
+ seen.add(key);
421
+ names.push(p.projectPath === projectPath ? `▶${p.modelName}` : p.modelName);
422
+ }
423
+ const shown = names.slice(0, PROJECT_NAMES_SHOWN);
424
+ const rest = names.length - shown.length;
425
+ lines.push(`Projects : ${shown.join(', ')}${rest > 0 ? `, +${rest} more` : ''} ` +
426
+ `(switch: get_workspace_info(projectName="<ModelName>"))`);
413
427
  }
414
- lines.push(``);
415
- lines.push(`To switch project: call get_workspace_info with projectName = "<ModelName>"`);
416
428
  }
417
429
  // Index freshness — compare workspace mtimes vs last_indexed_at.
418
430
  try {
@@ -421,7 +433,7 @@ export function registerToolHandler(server, context) {
421
433
  ? nodePath.join(effectiveWritePath, modelName)
422
434
  : null;
423
435
  const staleness = checkIndexStaleness(lastIndexedAt, modelMetadataDir);
424
- lines.push('', ...staleness.lines);
436
+ lines.push(...(diagnostics ? ['', ...staleness.lines] : staleness.compactLines));
425
437
  }
426
438
  catch {
427
439
  // Freshness reporting is best-effort — never break get_workspace_info
@@ -471,11 +483,51 @@ export function registerToolHandler(server, context) {
471
483
  // Context Snapshot — recently edited objects + uncommitted X++ changes. Best-effort.
472
484
  try {
473
485
  const snapshot = await buildContextSnapshot(context);
474
- lines.push('', ...renderContextSnapshotSection(snapshot));
486
+ lines.push(...(diagnostics
487
+ ? ['', ...renderContextSnapshotSection(snapshot)]
488
+ : renderContextSnapshotCompact(snapshot)));
475
489
  }
476
490
  catch {
477
491
  // Snapshot is additive — omit silently on failure.
478
492
  }
493
+ // Everything below is a broken/unusual state. It sits last so the facts
494
+ // above stay one uninterrupted block in the normal case, and so the
495
+ // instruction the agent must act on is the last thing it reads.
496
+ // A switch moves which project is ACTIVE — nothing else. Reads never
497
+ // needed it (they span every model regardless) and writes stay anchored
498
+ // to the model the workspace resolved on its own, so switching cannot be
499
+ // used to reach an object the cross-model guard refused. Say both here,
500
+ // before anything is written, so the switch is not mistaken for access.
501
+ const toolSwitch = configManager.getToolProjectSwitch();
502
+ if (toolSwitch) {
503
+ lines.push(``, `## ⚠️ Project switched — writes are NOT switched`, ``, `"${toolSwitch.forcedModel}" is now the ACTIVE project. This did not change what you ` +
504
+ `can read: get_object_info, search and find_references span every model, switched or ` +
505
+ `not, so a switch is never needed to look at another model's code. Writes stay ` +
506
+ `anchored to "${toolSwitch.anchorModel}" — the model the open workspace targets — and ` +
507
+ `a create/modify into "${toolSwitch.forcedModel}" will be refused.`, `Tell the user the model they asked about is owned by "${toolSwitch.forcedModel}" and let ` +
508
+ `THEM decide: extend it from "${toolSwitch.anchorModel}", or allow the write by adding ` +
509
+ `D365FO_CROSS_MODEL_WRITE_MODELS=${toolSwitch.forcedModel} to the server's .env — that ` +
510
+ `applies to the next attempt, no restart. Do not decide this on your own.`);
511
+ }
512
+ if (isPlaceholder) {
513
+ const rawDetectedModel = await configManager.getRawAutoDetectedModelName();
514
+ const detectedHint = rawDetectedModel
515
+ ? `> ✅ Auto-detected from .rnrproj: **${rawDetectedModel}**\n` +
516
+ `> Update your .mcp.json: set \`modelName\` to \`"${rawDetectedModel}"\``
517
+ : `> ⚠️ No .rnrproj was found — make sure the MCP server is running in the right directory.`;
518
+ lines.push(``, `⛔ CONFIGURATION PROBLEM — model name "${modelName}" is a placeholder, not a real D365FO model.`, ``, `**YOU MUST STOP** and tell the user:`, `> The configured model name "${modelName}" is a placeholder.`, detectedHint, `>`, `> Please check that:`, `> 1. The MCP server is running in the correct workspace directory`, `> 2. The .mcp.json / mcp.json file has the correct modelName`, `> 3. The projectPath points to a valid .rnrproj file`, `>`, `> Do you want to fix the configuration first, or continue with built-in tools (limited functionality)?`);
519
+ }
520
+ else if (isStandardMsModel) {
521
+ const customCandidates = allProjects.filter(p => isCustomModel(p.modelName));
522
+ const hint = customCandidates.length > 0
523
+ ? `Available custom models: ${customCandidates.map(p => p.modelName).join(', ')}\n` +
524
+ `Switch with: get_workspace_info(projectName="<model>")`
525
+ : `No custom models found under D365FO_SOLUTIONS_PATH. Check your project configuration.`;
526
+ lines.push(``, `⛔ CONFIGURATION PROBLEM — model name "${modelName}" is a Microsoft standard/demo model, not a custom model.`, ``, `**YOU MUST STOP** and tell the user:`, `> The auto-detected model "${modelName}" is a Microsoft standard model.`, `> This usually happens when a new VS project was created and the default model`, `> in the project wizard ("FleetManagement") was not changed to the correct custom model.`, `>`, `> How to fix:`, `> 1. In Visual Studio, open the .rnrproj file and change <Model>FleetManagement</Model>`, `> to the correct model name (e.g. <Model>ContosoCore</Model>).`, `> 2. OR explicitly switch to a known project:`, `> ${hint}`, `> 3. OR add the correct modelName to .mcp.json.`);
527
+ }
528
+ else if (diagnostics) {
529
+ lines.push(``, `✅ Configuration looks valid. Proceed with D365FO operations using model "${modelName}".`);
530
+ }
479
531
  return { content: [{ type: 'text', text: lines.join('\n') }] };
480
532
  }
481
533
  default:
@@ -26,7 +26,7 @@ export interface AddControlPatternVerdict {
26
26
  allowedTypes: string[] | 'any';
27
27
  }
28
28
  /**
29
- * Pre-flight for modify_d365fo_file(add-control): when the target parent
29
+ * Pre-flight for d365fo_file(action="modify", operation="add-control"): when the target parent
30
30
  * container declares a sub-pattern, check the new control's type against the
31
31
  * children that sub-pattern allows. Returns null when the parent cannot be
32
32
  * found, declares no pattern, or the pattern is unknown — those cases never
@@ -115,7 +115,7 @@ export function isFormPatternEnforceEnabled() {
115
115
  return v !== 'false' && v !== '0' && v !== 'off';
116
116
  }
117
117
  /**
118
- * Pre-flight for modify_d365fo_file(add-control): when the target parent
118
+ * Pre-flight for d365fo_file(action="modify", operation="add-control"): when the target parent
119
119
  * container declares a sub-pattern, check the new control's type against the
120
120
  * children that sub-pattern allows. Returns null when the parent cannot be
121
121
  * found, declares no pattern, or the pattern is unknown — those cases never
@@ -20,7 +20,14 @@ export interface MtimeScanResult {
20
20
  export declare function findNewestMetadataMtime(rootDir: string): MtimeScanResult | null;
21
21
  export interface StalenessReport {
22
22
  status: 'fresh' | 'stale' | 'unknown';
23
+ /** Full "## Index Freshness" section — diagnostics=true only. */
23
24
  lines: string[];
25
+ /**
26
+ * Compact default: one `Index : …` line, and the fix only when the index is
27
+ * actually stale. The scan detail (newest file, files scanned) is diagnostics
28
+ * material — it changes nothing about what the agent should do next.
29
+ */
30
+ compactLines: string[];
24
31
  }
25
32
  /**
26
33
  * Compare workspace mtimes against the index timestamp and render a report
@@ -78,27 +78,50 @@ export function checkIndexStaleness(lastIndexedAt, modelMetadataDir) {
78
78
  const lines = ['## Index Freshness', ''];
79
79
  if (!lastIndexedAt) {
80
80
  lines.push('ℹ️ Index has no freshness timestamp yet (built before this feature or never built).', ' It will be recorded on the next build-database run or update_symbol_index call.');
81
- return { status: 'unknown', lines };
81
+ return {
82
+ status: 'unknown',
83
+ lines,
84
+ compactLines: ['Index : no freshness timestamp yet (never indexed?)'],
85
+ };
82
86
  }
83
87
  const indexedAtMs = Date.parse(lastIndexedAt);
84
88
  const ageHours = Math.round((Date.now() - indexedAtMs) / 3_600_000);
85
89
  lines.push(`Last indexed : ${lastIndexedAt} (${ageHours} h ago)`);
86
90
  if (!modelMetadataDir) {
87
91
  lines.push('ℹ️ Model metadata folder not resolved — cannot compare workspace mtimes.');
88
- return { status: 'unknown', lines };
92
+ return {
93
+ status: 'unknown',
94
+ lines,
95
+ compactLines: [`Index : indexed ${ageHours} h ago (model folder not resolved — not compared)`],
96
+ };
89
97
  }
90
98
  const scan = findNewestMetadataMtime(modelMetadataDir);
91
99
  if (!scan) {
92
100
  lines.push(`ℹ️ No metadata files found under ${modelMetadataDir} — nothing to compare.`);
93
- return { status: 'unknown', lines };
101
+ return {
102
+ status: 'unknown',
103
+ lines,
104
+ compactLines: [`Index : indexed ${ageHours} h ago (no metadata files to compare)`],
105
+ };
94
106
  }
95
107
  lines.push(`Newest file : ${path.basename(scan.newestFile)} (${new Date(scan.newestMtime).toISOString()})` +
96
108
  (scan.truncated ? ` — scanned first ${MAX_SCANNED_FILES} files` : ` — ${scan.scannedFiles} files scanned`));
97
109
  if (scan.newestMtime > indexedAtMs + TOLERANCE_MS) {
98
110
  lines.push('', '⚠️ **INDEX IS STALE** — the workspace contains files newer than the last index update.', ` Newest change: ${scan.newestFile}`, ' Symbol lookups may return outdated signatures/fields for recently edited objects.', ` Fix: call \`update_symbol_index(filePath="${scan.newestFile.replace(/\\/g, '\\\\')}")\` for the changed file(s),`, ' or run `npm run build-database` (EXTRACT_MODE=custom) for a full custom-model refresh.');
99
- return { status: 'stale', lines };
111
+ return {
112
+ status: 'stale',
113
+ lines,
114
+ compactLines: [
115
+ `Index : ⚠️ STALE — indexed ${ageHours} h ago, workspace has newer files (lookups may be outdated)`,
116
+ ` Fix: update_symbol_index(filePath="${scan.newestFile.replace(/\\/g, '\\\\')}")`,
117
+ ],
118
+ };
100
119
  }
101
120
  lines.push('✅ Index is up to date with the workspace.');
102
- return { status: 'fresh', lines };
121
+ return {
122
+ status: 'fresh',
123
+ lines,
124
+ compactLines: [`Index : up to date (indexed ${ageHours} h ago)`],
125
+ };
103
126
  }
104
127
  //# sourceMappingURL=indexStaleness.js.map
@@ -60,9 +60,11 @@ export interface ContextSnapshot {
60
60
  */
61
61
  export declare function buildContextSnapshot(context: XppServerContext): Promise<ContextSnapshot>;
62
62
  /**
63
- * Render the snapshot's "live" portion (recent objects + uncommitted changes)
64
- * as markdown lines for embedding in get_workspace_info. Identity/prefix/index
65
- * sections are already covered by that tool, so this only adds what is new.
63
+ * The same "live" portion as renderContextSnapshotSection, folded into at most
64
+ * two lines for get_workspace_info's default output. Names only enough recent
65
+ * objects to orient the agent the full list, with timestamps and every
66
+ * uncommitted path, stays behind diagnostics=true and review_workspace_changes.
66
67
  */
68
+ export declare function renderContextSnapshotCompact(snapshot: ContextSnapshot): string[];
67
69
  export declare function renderContextSnapshotSection(snapshot: ContextSnapshot): string[];
68
70
  //# sourceMappingURL=contextSnapshot.d.ts.map
@@ -160,6 +160,28 @@ export async function buildContextSnapshot(context) {
160
160
  * as markdown lines for embedding in get_workspace_info. Identity/prefix/index
161
161
  * sections are already covered by that tool, so this only adds what is new.
162
162
  */
163
+ /** How many recent objects the compact rendering names before summarising. */
164
+ const COMPACT_RECENT_SHOWN = 3;
165
+ /**
166
+ * The same "live" portion as renderContextSnapshotSection, folded into at most
167
+ * two lines for get_workspace_info's default output. Names only enough recent
168
+ * objects to orient the agent — the full list, with timestamps and every
169
+ * uncommitted path, stays behind diagnostics=true and review_workspace_changes.
170
+ */
171
+ export function renderContextSnapshotCompact(snapshot) {
172
+ const lines = [];
173
+ if (snapshot.recentObjects.length > 0) {
174
+ const shown = snapshot.recentObjects
175
+ .slice(0, COMPACT_RECENT_SHOWN)
176
+ .map(o => `${o.name} [${o.type}]`);
177
+ const rest = snapshot.recentObjects.length - shown.length;
178
+ lines.push(`Recent edits: ${shown.join(', ')}${rest > 0 ? ` (+${rest} more)` : ''}`);
179
+ }
180
+ if (snapshot.uncommittedFiles.length > 0) {
181
+ lines.push(`Uncommitted : ${snapshot.uncommittedFiles.length} X++ file(s) — review_workspace_changes`);
182
+ }
183
+ return lines;
184
+ }
163
185
  export function renderContextSnapshotSection(snapshot) {
164
186
  const lines = ['## Context Snapshot', ''];
165
187
  if (snapshot.activeObject) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "d365fo-mcp",
3
- "version": "1.8.5",
3
+ "version": "1.9.0",
4
4
  "description": "MCP Server for X++ Code Completion in D365 Finance & Operations",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",