d365fo-mcp 1.5.0 → 1.5.2

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.
@@ -0,0 +1,86 @@
1
+ # D365 Finance & Operations X++ Development
2
+
3
+ <!-- Thin pointer — full rules are delivered via the MCP `xpp_system_instructions` prompt.
4
+ This file provides only the minimum static context needed when the MCP server
5
+ is not yet connected or the prompt hasn't been loaded.
6
+ Serves Claude Code too: copy it to a parent of your solution folders as
7
+ CLAUDE.md (docs/SETUP.md § Claude Code CLI, Step 3). -->
8
+
9
+ ## Tool Priority
10
+
11
+ This workspace contains a D365FO MCP server. **Always use the specialized MCP tools** for D365FO objects (`.xml`, `.xpp`, `.rnrproj`, `.label.txt`). Built-in file/search tools are fine for `.cs`, `.json`, `.yml`, `.md`, `.config` files.
12
+
13
+ ## Mandatory First Check
14
+
15
+ Call `get_workspace_info()` before doing anything with D365FO objects.
16
+
17
+ | Response | Action |
18
+ |----------|--------|
19
+ | Call fails | STOP. MCP server not connected. Ask user to start it. |
20
+ | `⛔ CONFIGURATION PROBLEM` | STOP. Relay message. Wait for user. |
21
+ | `✅ Configuration looks valid` | Note model name. Proceed. |
22
+
23
+ ## Terminal Prohibition
24
+
25
+ PowerShell / any terminal command **WILL HANG** in VS 2022 / VS 2026 MCP integration. Never use `run_in_terminal` or generate scripts as a fallback when an MCP tool fails — STOP and report the error verbatim.
26
+
27
+ ## Core Tool Mapping
28
+
29
+ | Action | Tool |
30
+ |--------|------|
31
+ | Plan an extension before changing code | `prepare(mode="change", goal, objectName, methodName?)` — returns signature, existing CoC wrappers, strategy + `groundingToken` |
32
+ | Plan a new object before creating it | `prepare(mode="create", goal, objectName, objectType)` — returns collision check, naming, EDT/label hints + `groundingToken` |
33
+ | Create a D365FO object | `d365fo_file(action="create")` (never `create_file`) |
34
+ | Edit an existing object | `d365fo_file(action="modify")` (applies immediately — confirm in chat first) |
35
+ | Revert the last write | `undo_last_modification` |
36
+ | Search objects | `search` — multiple via `search(queries[])`, custom-only via `search(scope="extensions")` |
37
+ | Read any object's metadata | `get_object_info(objectType, name, options?)` — objectType ∈ class/table/form/query/view/enum/edt/report/data-entity/menu-item/service/map/config-key/security-policy/macro. 2+ known names: `batch_get_info(objects[])` |
38
+ | Method signature for CoC | `get_method(include="signature")` (already returned by `prepare(mode="change")`) |
39
+ | Validate X++ before write | `validate_code(mode="syntax", code)` — offline BP check, <50 ms |
40
+ | X++ rules & patterns | `get_knowledge(kind="knowledge", topic)` — select grammar, CoC, BP rules, SysOperation, workflow, … |
41
+ | Create a NEW form | `object_patterns(domain="form", action="analyze", recommend={...})` → `object_patterns(domain="form", action="spec", pattern)` → `generate_object(mode="scaffold", objectType="form", cloneFrom=referenceForm, tableMapping={...})` → `object_patterns(domain="form", action="validate", xml)` |
42
+ | Validate form XML against its pattern | `object_patterns(domain="form", action="validate", xml \| formName \| filePath)` — structural errors block form writes (FORM_PATTERN_ENFORCE) |
43
+ | Resolve label / EDT / class refs | `validate_code(mode="references", code)` |
44
+ | Build / BP / Sync | `build_d365fo_project` / `run_bp_check` / `trigger_db_sync` |
45
+ | Error diagnosis | `get_knowledge(kind="error", errorText)` |
46
+
47
+ ## Key Rules
48
+
49
+ ### Workspace & model targeting
50
+
51
+ 1. **The target model comes from `.mcp.json`** — never infer it from search results or object names. The symbol database contains objects from all models (Microsoft + ISV + custom); the model on a search/`get_*_info` result is the source model, not where new files belong.
52
+
53
+ ### Writes & file editing
54
+
55
+ 2. **`d365fo_file` (action=create/modify) applies immediately** (no dry-run / preview). Describe the change in chat and wait for explicit user confirmation ("apply", "ok", "yes") before calling. Revert with `undo_last_modification` (or pass `createBackup=true` to keep a `.bak`).
56
+ 3. **Never** use `replace_string_in_file`, `edit_file`, `apply_patch`, or any built-in file-write tool on `.xml` or `.xpp` files — **not even as a fallback** when `d365fo_file(action="modify")` fails. These bypass `IMetadataProvider` and corrupt VS 2022's in-memory model. If `d365fo_file(action="modify")` errors, STOP and report the error verbatim.
57
+
58
+ ### Build automation
59
+
60
+ 4. Never run `build_d365fo_project()` automatically — only on explicit user request ("build", "compile", "check errors").
61
+
62
+ ### X++ correctness (BP-clean code)
63
+
64
+ 5. Never copy default parameter values into CoC wrapper signatures.
65
+ 6. Never use `today()` — use `DateTimeUtil::getToday(DateTimeUtil::getUserPreferredTimeZone())`.
66
+ 7. Never use hardcoded strings in `Info()` / `warning()` / `error()` — use `@Model:Label` references.
67
+ 8. Call `labels(action="search")` before `labels(action="create")` — reuse existing labels.
68
+
69
+ ### Extension naming
70
+
71
+ 9. Extension naming follows `EXTENSION_NAMING_STYLE` (see `get_workspace_info`):
72
+ - `prefix` (default) → class `{Target}{Prefix}_Extension`, element `{Target}.{Prefix}Extension`
73
+ - `model-name` → class `{Target}_{ModelName}_Extension`, element `{Target}.{ModelName}`
74
+
75
+ Pass the BASE object name to `d365fo_file(action="create")` and let the tool inject the token — don't hand-build the infix.
76
+
77
+ ### Reuse & diff safety
78
+
79
+ 10. **Reuse before creating** — `prepare(mode="change")` lists existing CoC wrappers and event handlers. If an extension or handler class in the custom model already owns the target, add the new method there. Never create a parallel feature-named class (`<Target>_<Feature>_Extension`, `<Form>_<Feature>_EH`) unless the user explicitly asks for a separate class. The suffix comes from `EXTENSION_NAMING_STYLE` / existing artifacts — never from feature, ticket, or customer names; if it cannot be derived, ask.
80
+ 11. **The post-write diff must be additive or narrowly targeted** — verify via `review_workspace_changes` (or re-read with `get_*_info`) that no unrelated XML nodes (`<DataSources>`, `<Controls>`, methods, pattern metadata) disappeared. If they did, the edit failed: `undo_last_modification`.
81
+ 12. **An example form named by the user is a pattern contract** — keep its pattern family and required scaffolding (datasources, ActionPane/Tab/grid/QuickFilter); missing pattern elements are a failed generation even if the XML is well-formed.
82
+
83
+ ## Full Instructions
84
+
85
+ The complete X++ rules, query grammar, CoC authoring rules, and workflow details are delivered via the MCP prompt `xpp_system_instructions`. If that prompt is not loaded, request it or consult [src/prompts/systemInstructions.ts](../src/prompts/systemInstructions.ts) directly.
86
+
@@ -0,0 +1,18 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <!--
3
+ D365FO dev VMs frequently ship a machine-wide NuGet.config that only lists
4
+ offline/VS package sources (e.g. "C:\Program Files\dotnet\library-packs" and
5
+ "Microsoft Visual Studio Offline Packages") — nuget.org isn't in it, so
6
+ restoring System.Text.Json / Microsoft.NETFramework.ReferenceAssemblies.net48
7
+ fails with NU1101 even though the machine has internet access.
8
+
9
+ This file has no <clear/>, so NuGet MERGES it with whatever the machine/user
10
+ config already defines — it only adds nuget.org, it never removes the
11
+ offline sources the D365FO tooling relies on. See docs/SETUP.md
12
+ "Restrictive NuGet feed".
13
+ -->
14
+ <configuration>
15
+ <packageSources>
16
+ <add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
17
+ </packageSources>
18
+ </configuration>
@@ -8,6 +8,7 @@ import * as fs from 'node:fs';
8
8
  import { resolve } from 'node:path';
9
9
  import { settingByPath, settingsInSection } from '../../config/settings.js';
10
10
  import { pinBridgeExe } from '../bridgePath.js';
11
+ import { finalizeStagedCopilotFiles, maybePrepareCopilotInstructions } from '../copilotFiles.js';
11
12
  import { createInstance, getInstance, listInstances, normalizeInstanceLayout, suggestPort } from '../instances.js';
12
13
  import { mcpJsonNote, placementNote, stdioServer } from '../mcpJson.js';
13
14
  import { selectXppConfig } from './config.js';
@@ -98,17 +99,23 @@ export async function instanceAddCommand(name, portArg) {
98
99
  p.log.step('Workspace and naming');
99
100
  await askSetting(store, settingByPath('workspace.modelName'));
100
101
  await askSetting(store, settingByPath('workspace.path'));
102
+ await askSetting(store, settingByPath('workspace.solutionsPath'));
101
103
  await askSettings(store, settingsInSection('naming', 'basic'));
102
104
  p.log.step('Metadata index');
103
105
  await askSettings(store, settingsInSection('index', 'basic'));
104
106
  await askAdvanced(store, ['environment', 'workspace', 'index', 'server', 'bridge', 'behavior']);
105
107
  saveStore(store);
108
+ // Copilot needs the instructions file just as much as it needs .mcp.json,
109
+ // and an instance is somebody's only setup run — asking here is the only
110
+ // chance scenario F gets.
111
+ const copilotPlan = await maybePrepareCopilotInstructions(String(readSetting(store, settingByPath('workspace.solutionsPath')) ?? ''));
106
112
  // Both ways to reach this instance: the IDE spawning it over stdio with its
107
113
  // own config, or an HTTP client on the port it was given.
108
114
  mcpJsonNote({
109
115
  [`d365fo-${instanceName}`]: stdioServer(store),
110
116
  [`d365fo-${instanceName}-http`]: { url: `http://localhost:${port}/mcp/` },
111
117
  }, `.mcp.json — keep the stdio entry OR the http one, not both`);
118
+ finalizeStagedCopilotFiles(copilotPlan);
112
119
  placementNote();
113
120
  p.note(`Config: ${store.configPath}\n\n` +
114
121
  `1. Build the index: d365fo-mcp instance rebuild ${instanceName}\n` +
@@ -15,6 +15,7 @@ import { DOTNET_MISSING, dataRoot, installMode, isWindows, paths, repoRoot, setD
15
15
  import { settingByPath, settingsInSection } from '../../config/settings.js';
16
16
  import { commandExists, runExe, runShell } from '../exec.js';
17
17
  import { pinBridgeExe } from '../bridgePath.js';
18
+ import { finalizeStagedCopilotFiles, maybePrepareCopilotInstructions } from '../copilotFiles.js';
18
19
  import { mcpJsonNote, placementNote, stdioServer } from '../mcpJson.js';
19
20
  import { checkRelease } from '../npmRegistry.js';
20
21
  import { askAdvanced, askSecrets, askSetting, askSettings } from '../settingsPrompt.js';
@@ -198,6 +199,10 @@ async function configureWorkspace(store, scenario) {
198
199
  await askSetting(store, setting('workspace.path'), { required: scenario === 'hybrid' || scenario === 'ude' });
199
200
  await askSetting(store, setting('workspace.solutionsPath'));
200
201
  }
202
+ /** Where the user keeps the .sln folders — the copy target for the client files. */
203
+ function solutionsPath(store) {
204
+ return String(readSetting(store, setting('workspace.solutionsPath')) ?? '');
205
+ }
201
206
  async function configureNaming(store) {
202
207
  p.log.step('Naming');
203
208
  await askSettings(store, settingsInSection('naming', 'basic'));
@@ -216,67 +221,6 @@ async function maybeBuildIndex() {
216
221
  }
217
222
  return rebuildIndex(rootTarget());
218
223
  }
219
- function writeCopilotSetupReadme(stageDir, includeLocalMcpCopy) {
220
- const readmePath = resolve(stageDir, 'README.md');
221
- const lines = [
222
- '# VS setup quick guide',
223
- '',
224
- 'This folder is generated by `d365fo-mcp setup` to simplify Visual Studio onboarding.',
225
- '',
226
- '1. Copy .mcp.json',
227
- ' - Recommended (all solutions): %USERPROFILE%\\.mcp.json',
228
- ' - Per solution: place .mcp.json next to your .sln file',
229
- includeLocalMcpCopy
230
- ? ' - Local copy prepared here: .mcp.json'
231
- : ` - Local copy path: ${paths.mcpSuggestion}`,
232
- '',
233
- '2. Copy .github/copilot-instructions.md',
234
- ' - Destination: a parent folder of your solution directories',
235
- ' - Why: provides mandatory D365FO tool-routing and safety rules for Copilot',
236
- '',
237
- '3. Restart Visual Studio after copying files.',
238
- ];
239
- fs.writeFileSync(readmePath, lines.join('\n') + '\n', 'utf8');
240
- }
241
- async function maybePrepareCopilotInstructions(store) {
242
- const source = resolve(repoRoot, '.github', 'copilot-instructions.md');
243
- if (!fs.existsSync(source)) {
244
- p.log.warn('Cannot find .github\\copilot-instructions.md in the package; skipping copy helper.');
245
- return {};
246
- }
247
- const wantsDirectCopy = await askConfirm('Create/copy .github/copilot-instructions.md into the solutions folder now?', true);
248
- const solutionsPath = String(readSetting(store, setting('workspace.solutionsPath')) ?? '').trim();
249
- if (wantsDirectCopy && solutionsPath) {
250
- const targetDir = resolve(solutionsPath, '.github');
251
- fs.mkdirSync(targetDir, { recursive: true });
252
- fs.copyFileSync(source, resolve(targetDir, 'copilot-instructions.md'));
253
- p.log.success(`Prepared: ${resolve(targetDir, 'copilot-instructions.md')}`);
254
- return {};
255
- }
256
- const stageDir = resolve(dataRoot(), 'copilot-setup');
257
- const stageGitHubDir = resolve(stageDir, '.github');
258
- fs.mkdirSync(stageGitHubDir, { recursive: true });
259
- fs.copyFileSync(source, resolve(stageGitHubDir, 'copilot-instructions.md'));
260
- writeCopilotSetupReadme(stageDir, false);
261
- if (wantsDirectCopy && !solutionsPath) {
262
- p.log.warn('Solutions folder is empty, so files were prepared in the local staging folder instead.');
263
- }
264
- else {
265
- p.log.info('Copy was skipped; files were prepared in a local staging folder for later use.');
266
- }
267
- p.log.info(`Staging folder: ${stageDir}`);
268
- return { stagingDir: stageDir };
269
- }
270
- function finalizeStagedCopilotFiles(plan) {
271
- if (!plan.stagingDir)
272
- return;
273
- const localMcp = paths.mcpSuggestion;
274
- if (fs.existsSync(localMcp)) {
275
- fs.copyFileSync(localMcp, resolve(plan.stagingDir, '.mcp.json'));
276
- writeCopilotSetupReadme(plan.stagingDir, true);
277
- p.log.success(`Prepared: ${resolve(plan.stagingDir, '.mcp.json')}`);
278
- }
279
- }
280
224
  export function savedNote(store) {
281
225
  const rel = relative(dataRoot(), store.configPath) || store.configPath;
282
226
  const lines = [`Settings written to ${rel}`];
@@ -332,7 +276,7 @@ export async function setupCommand() {
332
276
  const url = await askText({ message: 'Azure server URL', placeholder: 'https://your-server.azurewebsites.net/mcp/', required: true });
333
277
  await configureEnvironment(store, scenario);
334
278
  await configureWorkspace(store, scenario);
335
- const copilotPlan = await maybePrepareCopilotInstructions(store);
279
+ const copilotPlan = await maybePrepareCopilotInstructions(solutionsPath(store));
336
280
  await configureNaming(store);
337
281
  await askSecrets(store, ['behavior']);
338
282
  await askAdvanced(store, ['environment', 'workspace', 'naming', 'bridge', 'behavior', 'server']);
@@ -351,7 +295,7 @@ export async function setupCommand() {
351
295
  writeSetting(store, setting('server.mode'), 'full');
352
296
  await configureEnvironment(store, scenario);
353
297
  await configureWorkspace(store, scenario);
354
- const copilotPlan = await maybePrepareCopilotInstructions(store);
298
+ const copilotPlan = await maybePrepareCopilotInstructions(solutionsPath(store));
355
299
  await configureNaming(store);
356
300
  await configureIndex(store);
357
301
  let port = Number(readSetting(store, setting('server.port')) ?? 8080);
@@ -0,0 +1,15 @@
1
+ export type CopilotFilePlan = {
2
+ stagingDir?: string;
3
+ };
4
+ /**
5
+ * Offer to place copilot-instructions.md, falling back to a staging folder.
6
+ *
7
+ * `solutionsPath` is what the user gave for workspace.solutionsPath; when it
8
+ * is empty there is nowhere to copy to, so the files are staged instead and
9
+ * the returned plan tells {@link finalizeStagedCopilotFiles} to complete it
10
+ * once the .mcp.json exists.
11
+ */
12
+ export declare function maybePrepareCopilotInstructions(solutionsPath: string): Promise<CopilotFilePlan>;
13
+ /** Add the generated .mcp.json to the staging folder — call after mcpJsonNote(). */
14
+ export declare function finalizeStagedCopilotFiles(plan: CopilotFilePlan): void;
15
+ //# sourceMappingURL=copilotFiles.d.ts.map
@@ -0,0 +1,96 @@
1
+ /**
2
+ * The `.github\copilot-instructions.md` half of client setup.
3
+ *
4
+ * `.mcp.json` only tells the IDE how to *start* the server; without the
5
+ * instructions file in a parent of the solution folder Copilot keeps using its
6
+ * built-in file tools and edits X++ behind the metadata provider's back. Both
7
+ * files therefore have to be placed for a working install, so the wizards
8
+ * offer to put this one down as well — either straight into the solutions
9
+ * folder, or, when that is unknown, into a staging folder next to the
10
+ * generated .mcp.json with a README naming the two destinations.
11
+ *
12
+ * Shared by `setup` (scenarios B–E) and `instance add` (scenario F), which
13
+ * both end on the same two-file placement step.
14
+ */
15
+ import * as fs from 'node:fs';
16
+ import { resolve } from 'node:path';
17
+ import { dataRoot, paths, repoRoot } from './context.js';
18
+ import { askConfirm, p } from './ui.js';
19
+ /** The copy shipped with this installation — package root in both install modes. */
20
+ const copilotSource = () => resolve(repoRoot, '.github', 'copilot-instructions.md');
21
+ function writeCopilotSetupReadme(targetDir, opts) {
22
+ const readmePath = resolve(targetDir, 'README.md');
23
+ const lines = [
24
+ '# VS setup quick guide',
25
+ '',
26
+ 'This file is generated by `d365fo-mcp setup` to simplify Visual Studio onboarding.',
27
+ '',
28
+ '1. Copy .mcp.json',
29
+ ' - Recommended (all solutions): %USERPROFILE%\\.mcp.json',
30
+ ' - Per solution: place .mcp.json next to your .sln file',
31
+ opts.includeLocalMcpCopy
32
+ ? ' - Local copy prepared here: .mcp.json'
33
+ : ` - Local copy path: ${paths.mcpSuggestion}`,
34
+ '',
35
+ '2. Copy .github/copilot-instructions.md',
36
+ opts.copilotAlreadyPlaced
37
+ ? ' - Already placed here: .github\\copilot-instructions.md'
38
+ : ' - Destination: a parent folder of your solution directories',
39
+ ' - Why: provides mandatory D365FO tool-routing and safety rules for Copilot',
40
+ '',
41
+ '3. Restart Visual Studio after copying files.',
42
+ ];
43
+ fs.writeFileSync(readmePath, lines.join('\n') + '\n', 'utf8');
44
+ }
45
+ /**
46
+ * Offer to place copilot-instructions.md, falling back to a staging folder.
47
+ *
48
+ * `solutionsPath` is what the user gave for workspace.solutionsPath; when it
49
+ * is empty there is nowhere to copy to, so the files are staged instead and
50
+ * the returned plan tells {@link finalizeStagedCopilotFiles} to complete it
51
+ * once the .mcp.json exists.
52
+ */
53
+ export async function maybePrepareCopilotInstructions(solutionsPath) {
54
+ const source = copilotSource();
55
+ if (!fs.existsSync(source)) {
56
+ p.log.warn('Cannot find copilot-instructions.md in this installation; skipping copy helper.\n' +
57
+ ` Looked for: ${source}`);
58
+ return {};
59
+ }
60
+ const wantsDirectCopy = await askConfirm('Create/copy .github/copilot-instructions.md into the solutions folder now?', true);
61
+ const target = solutionsPath.trim();
62
+ if (wantsDirectCopy && target) {
63
+ const targetDir = resolve(target, '.github');
64
+ fs.mkdirSync(targetDir, { recursive: true });
65
+ fs.copyFileSync(source, resolve(targetDir, 'copilot-instructions.md'));
66
+ writeCopilotSetupReadme(target, { includeLocalMcpCopy: false, copilotAlreadyPlaced: true });
67
+ p.log.success(`Prepared: ${resolve(targetDir, 'copilot-instructions.md')}`);
68
+ p.log.success(`Prepared: ${resolve(target, 'README.md')}`);
69
+ return {};
70
+ }
71
+ const stageDir = resolve(dataRoot(), 'copilot-setup');
72
+ const stageGitHubDir = resolve(stageDir, '.github');
73
+ fs.mkdirSync(stageGitHubDir, { recursive: true });
74
+ fs.copyFileSync(source, resolve(stageGitHubDir, 'copilot-instructions.md'));
75
+ writeCopilotSetupReadme(stageDir, { includeLocalMcpCopy: false, copilotAlreadyPlaced: false });
76
+ if (wantsDirectCopy && !target) {
77
+ p.log.warn('Solutions folder is empty, so files were prepared in the local staging folder instead.');
78
+ }
79
+ else {
80
+ p.log.info('Copy was skipped; files were prepared in a local staging folder for later use.');
81
+ }
82
+ p.log.info(`Staging folder: ${stageDir}`);
83
+ return { stagingDir: stageDir };
84
+ }
85
+ /** Add the generated .mcp.json to the staging folder — call after mcpJsonNote(). */
86
+ export function finalizeStagedCopilotFiles(plan) {
87
+ if (!plan.stagingDir)
88
+ return;
89
+ const localMcp = paths.mcpSuggestion;
90
+ if (fs.existsSync(localMcp)) {
91
+ fs.copyFileSync(localMcp, resolve(plan.stagingDir, '.mcp.json'));
92
+ writeCopilotSetupReadme(plan.stagingDir, { includeLocalMcpCopy: true, copilotAlreadyPlaced: false });
93
+ p.log.success(`Prepared: ${resolve(plan.stagingDir, '.mcp.json')}`);
94
+ }
95
+ }
96
+ //# sourceMappingURL=copilotFiles.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "d365fo-mcp",
3
- "version": "1.5.0",
3
+ "version": "1.5.2",
4
4
  "description": "MCP Server for X++ Code Completion in D365 Finance & Operations",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -11,6 +11,7 @@
11
11
  "dist",
12
12
  "!dist/**/*.map",
13
13
  "!dist/eval/**",
14
+ ".github/copilot-instructions.md",
14
15
  "bridge/D365MetadataBridge",
15
16
  "!bridge/D365MetadataBridge/bin/**",
16
17
  "!bridge/D365MetadataBridge/obj/**"