feat!: proxy Microsoft's almcp, narrow ALCops MCP to code fixes, require BC DevTools 18.0 - #20
Merged
Merged
Conversation
Locates the Microsoft AL MCP server (almcp.exe) via: 1. ALMCP_PATH environment variable 2. --almcp-path CLI argument 3. Auto-discovery from AL Language VS Code extension Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Manages the almcp child process lifecycle and per-call McpClient connections over HTTP (matching Microsoft's own proxy pattern). Includes AlMcpProxyStartup IHostedService for lifecycle management. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Uses WithListToolsHandler + WithCallToolHandler to dynamically proxy MS AL MCP tools alongside native ALCops tools. The SDK merges both tool sets automatically — no static tool wrappers needed. New CLI args: --almcp-path, --no-proxy, plus passthrough of analyzer-related args to the child almcp process. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…folder A plain PackageReference to Microsoft.Dynamics.BusinessCentral.Development.Tools is impossible: as of 17.0 the package dropped the Dependency package type and the lib/ folder, and NuGet rejects referencing a DotnetTool package. PackageDownload restores the nupkg into the global packages folder without referencing it, which gives the three <Reference> items a stable HintPath into tools/<tfm>/any/. src keeps Private=false so the proprietary DLLs never enter the build output and therefore never the published package — the redistribution guard that the now redundant ExcludeBcDevToolsFromPublish target used to provide. tests keep Private=true on purpose: the CI compatibility matrix hot-swaps those three files in the prebuilt test binary to run against every supported SDK version. CI no longer curls and unzips the nupkg by hand; restore provides it, and the version matrix is expressed as -p:BcDevToolsVersion. The release job now passes that version to pack as well, so the packed binary matches what was tested. ALCops.Analyzers moves to the test project: the fixtures need real cops with real code fixes, but nothing bundled may ship. Dependabot follows it there. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Microsoft's almcp has no code-fix capability at all (its LSP mode advertises CodeActionProvider = false) and no way to enumerate rules. Everything else — compiling, diagnostics, symbols, publishing, tests, translations — it already does, and PR #20 already proxies it. So the compile/diagnostics path, the bundled analyzers and the four-strategy DevTools resolution chain all become duplicated infrastructure maintained against a proprietary SDK. This deletes them. BREAKING: the `analyze` tool is gone; use the proxied `al_compile` with onlyErrors: false (it defaults to true, and nearly every ALCops rule is a warning). Analyzers are no longer bundled and must be configured through the project's own al.codeAnalyzers. Not bundling analyzers is what closes #10 structurally rather than papering over it: pinned cop DLLs loaded beside whatever Nav.CodeAnalysis the user has installed is exactly what produced AD0001 / MissingMethodException. Cops and compiler now both come from the user's toolchain, so we cannot mismatch them. One BcToolsLocator (~200 lines) replaces BcDevToolsBootstrap, DevToolsLocator, NuGetDevToolsDownloader, AlMcpLocator and AlExtensionLocator (~370). It works because almcp and the DevTools DLLs ship in the same directory in both delivery channels, which is also what guarantees our in-process fixes load the same Nav.CodeAnalysis the child almcp uses. Runtime NuGet download is gone: the server now fails fast with the install command instead of starting degraded. New WorkspaceStartupResolver bridges the project's own configuration into the child almcp at launch. almcp in MCP mode never reads .vscode/settings.json (its WorkspaceSettingsApplier is LSP-only) and exposes no per-call analyzer or ruleset parameter, so without this the child starts with zero analyzers and al_compile disagrees with our fix tools about what is suppressed. It discovers projects the same way almcp's own LSP mode does, then passes --projects, --codeanalyzers and --rulesetpath. ForwardAsync stays a generic passthrough with no per-tool rewriting; user-supplied args always win. Analyzer dependencies travel with their analyzer: almcp resolves them only among the paths it was handed and never probes the analyzer's own directory, so omitting ALCops.Common.dll or Microsoft.Dynamics.Nav.Analyzers.Common.dll turns every rule in that assembly into an AD0001 instead of a diagnostic. They are read from assembly metadata, not loaded. Also fixes two live defects found while verifying: - The proxy pointed at http://localhost:<port>/mcp/, but almcp calls MapMcp() with no pattern, so its endpoint is the server root. /mcp/ returns 404, and the readiness probe accepted any response — including that 404 — so startup "succeeded" and the handshake then failed. It has never actually connected. - ParseProxyOptions shared one `i + 1 < args.Length` guard across every passthrough flag and unconditionally consumed the next arg, so --nolog and --noauth either vanished (when last) or swallowed the following flag as their value. Split into almcp's three real arities, in a testable ProxyOptions.Parse. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
With nothing bundled, a fixture that declares no analyzers finds no diagnostics and every code-fix test would pass vacuously. Each fixture is now copied to temp and given an al.codeAnalyzers pointing at the cop DLLs beside the test binary — written at run time because the paths are absolute. That means these tests exercise exactly the path production uses: spec -> ExternalAnalyzerLoader -> AnalyzerAssemblyLoadContext, which is better coverage than the old built-in registry path it replaces. The existing guard assertions stay, and now carry the loaded-analyzer count so a silent regression to zero diagnostics reports why. ApplyFix_WritesModifiedContentToDisk drops its DiagnosticsRunner-based location discovery for the hardcoded line/column already used by the ruleset test, and CopyDirectory is recursive so fixture subdirectories come along. New coverage for the parts that had none and now carry the design: BcToolsLocator probe precedence and layouts, ProxyOptions arity handling, and WorkspaceStartupResolver's discovery plus the child argument list it composes — including that a suppressing ruleset reaches al_compile, which is the specific guarantee the startup bridge exists to provide. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Documents what the server actually is now — a code-fix server plus a proxy — and the new prerequisite, the DevTools dotnet tool with the AL extension as fallback. Records that analyzers must come from al.codeAnalyzers and why bundling them was the cause of #10 rather than a convenience. Calls out the al_compile onlyErrors: true default explicitly: nearly every ALCops rule is a warning, so callers who leave it alone see nothing. That stays a docs fix precisely because ForwardAsync must not rewrite call arguments. Removes the old five-strategy resolution-order section, which was also factually wrong — it listed the local cache before the dotnet tools store, the reverse of what the code did. copilot-instructions goes from ".NET 8" to .NET 10 and picks up the constraints a future change most needs to know: why the DevTools dependency is shaped the way it is, why analyzers must never move back to src, and why analyzer dependencies have to travel with their analyzer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…st net10.0 18.0.41 is the current stable release and the first to ship a net10.0 payload, matching our own TFM. Drops 17.x from the compatibility matrix. The net10.0 payload ships its own ModelContextProtocol.Core (1.0.0) next to the DLLs we reference, so MSBuild's dependency walk now reports MSB3277 against our 0.9.0-preview.2. Suppressed in both projects: we never reference the only consumer (Nav.Deployment), and BcToolsLocator's resolver is a fallback hook, so our own copy always wins at runtime. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Each forwarded tool call built a fresh McpClient — initialize, initialized, tools/call, DELETE — four round-trips per call. Create the client once after the child is ready and reuse it for discovery and every forward. almcp's HTTP transport evicts idle sessions (default 2h) with a 404, which the server never dispatched, so that one case is retried once on a fresh session; any other transport failure drops the cached client and surfaces the error unchanged. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The test project already restores the DevTools nupkg, which ships almcp next to the DLLs from 17.0 onward. Spawn it once per class and assert session reuse across calls plus reconnect-and-retry after the session is deleted out of band. Skips where almcp is not runnable (16.2, or the Linux CI runner — the package has no non-Windows almcp launcher). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Move .github/copilot-instructions.md to AGENTS.md so Copilot and other agents keep reading it, add CLAUDE.md importing it, pre-allow dotnet build/test/pack/restore in .claude/settings.json, register alcops-mcp in .mcp.json, and ignore .claude/settings.local.json. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ately AlMcpProxyStartup awaited the child launch, the 30s readiness poll and tool discovery inside IHostedService.StartAsync, and it is registered ahead of the SDK's stdio hosted service — so a slow or broken almcp blocked even `initialize`, although the native code-fix tools need no child at all. Start the proxy on a background task and expose AlMcpProxy.Ready. tools/list waits up to 10s for it so hosts that list once still get the full set; past that it answers with the native tools and arms a one-shot notifications/tools/list_changed for when almcp comes up. Early al_* calls wait for readiness instead of failing. StartAsync now creates its client through the same gate as ForwardAsync, closing a two-client race that concurrent startup would otherwise open. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
In HTTP mode almcp writes its banner, "Port: N" and project-load progress to stdout (Program.cs passes Console.WriteLine as the output sink). The proxy only redirected the child's stderr, so the child inherited our stdout and that text landed in the middle of the JSON-RPC stream — 17 non-JSON lines in a captured session, with the box-drawing banner arriving as mojibake. Capture stdout too and route both streams to our stderr logger at Debug. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Review finding #3 claimed the removed DevTools packageCachePaths entry cost us system symbols. It did not: no .app ships in the DevTools package or the AL extension, and the fixtures compile and fix without any .alpackages. What was actually missing is the one symbol-location setting the AL extension honours: al.packageCachePath. ProjectLoader hardcoded <project>/.alpackages and the startup bridge never passed --packagecachepath, so a multi-app repo with a shared symbol cache got missing-symbol errors from al_compile and symbol-less compilations in get_fixes, with no hint why. Read the setting (string or array) once, resolve it per project in ProjectLoader, pass it as written to almcp so it resolves per project too, and warn when no package cache directory exists at all. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… text --projects paths went to almcp verbatim: a workspace root or a typo failed there with a generic load error, while our own analyzer/ruleset resolution silently read from a folder with no .vscode/settings.json. Run each entry through the same discovery the working directory gets — use it if it is a project, otherwise scan beneath it — and ignore entries with nothing under them, with a warning naming which it was. Three tool descriptions still told clients "ALCops analyzers are always included"; nothing has been bundled since the un-bundling, and list_rules already says so. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
FindFreePort reserves an IPv4 loopback port and has to release it before almcp can bind it, so another process can take it in between; almcp then exits with "address already in use" and startup failed for good. Relaunch on a fresh port when the child exits before it is ready, up to three attempts. A child that is alive but serves no MCP endpoint still fails outright. Connect to 127.0.0.1 — the family the port was reserved on — instead of resolving localhost on every connection. almcp binds both loopback families, so this only removes a possible ::1 attempt per connect. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This was referenced Sep 12, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Reuse Microsoft's
almcpfor everything it already does, and narrow ALCops MCP to the two things it doesn't do at all.almcphas no code-fix capability — its LSP mode explicitly advertisesCodeActionProvider = false, and it exposes no MCP tool for it — and no way to enumerate rules. Everything else (compiling, diagnostics, symbols, publishing, tests, translations, object IDs) it already does well. So our compile/diagnostics path, our bundled analyzers, and our four-strategy DevTools resolution chain were duplicated infrastructure maintained against a proprietary SDK that changes shape between majors.This PR started as the proxy (dynamic tool registration, still the core), then finished the job — delete the duplication, bridge the project's own config into the child so both sides agree — and finally hardened the proxy after a review pass: one long-lived MCP session, non-blocking startup, and a clean stdio channel.
Breaking changes
18.0.41.39415, the first stable release with anet10.0payload (matching our own TFM); 17.x is dropped from the compatibility matrix.analyzeis removed. Use the proxiedal_compile. Note it defaults toonlyErrors: truewhile nearly every ALCops rule is a warning — callers must passonlyErrors: false. Documented rather than patched, becauseForwardAsyncstays a generic passthrough with no per-tool argument rewriting.al.codeAnalyzers(plus AL-GorulesetFileand thecustom.ruleset.json/app.ruleset.jsonconventions).dotnet tool install -g Microsoft.Dynamics.BusinessCentral.Development.Tools, or the AL VS Code extension. Nothing is downloaded at runtime any more; the server fails fast with the install command instead of starting degraded.--almcp-pathis gone, superseded by--devtools-path.almcpships in the same directory as the DevTools DLLs, so a separate override would reintroduce exactly the version skew this change eliminates.Closes #10 structurally
Bundling pinned
ALCops.*Cop.dllbeside whateverNav.CodeAnalysisthe user happened to install is what produced theAD0001/MissingMethodExceptionfailures — it was the cause, not a mitigation. Cops and compiler now both come from the user's own toolchain, so we cannot mismatch them. The same co-location means our in-processCodeFixRunnerloads the identicalNav.CodeAnalysisthe childalmcpuses.What changed
Build — a plain
PackageReferenceto the DevTools package is impossible: as of 17.0 it dropped theDependencypackage type and thelib/folder, and NuGet rejects referencing aDotnetToolpackage.PackageDownloadrestores it without referencing it, giving the three<Reference>items a stableHintPathintotools/<tfm>/any/.srcusesPrivate=falseso the proprietary DLLs never reach the package (the redistribution guardExcludeBcDevToolsFromPublishused to provide);testsusesPrivate=truebecause the CI matrix hot-swaps them. CI stops hand-rollingcurl+unzipand just passes-p:BcDevToolsVersion. The 18.x payload ships its ownModelContextProtocol.Core 1.0.0beside the referenced DLLs, which MSBuild's dependency walk flags (MSB3277) against our0.9.0-preview.2; suppressed with a why-comment, since we never reference the only consumer (Nav.Deployment) and our copy wins at runtime.One locator —
BcToolsLocator(~200 lines) replacesBcDevToolsBootstrap,DevToolsLocator,NuGetDevToolsDownloader,AlMcpLocatorandAlExtensionLocator(~370). Probe order:--devtools-path→BCDEVELOPMENTTOOLSPATH→ dotnet tool store → AL extensionbin/→ hard error, each step logged.Startup config bridge —
almcpin MCP mode never reads.vscode/settings.json(itsWorkspaceSettingsApplieris LSP-only) and has no per-call analyzer, ruleset or package-cache parameter. Without a bridge the child starts with zero analyzers andal_compilesilently disagrees with our fix tools about what is suppressed.WorkspaceStartupResolverdiscovers projects exactly the wayalmcp's own LSP mode does, then passes--projects/--codeanalyzers/--rulesetpath/--packagecachepathat launch.--projectsentries get the same treatment as the working directory (a workspace root expands to the projects beneath it; entries with none are ignored with a warning). User-supplied args always win.ProjectLoaderreads the sameal.packageCachePathfor the in-process compilation, so a multi-app repo with one shared symbol cache works on both sides.Proxy hardening (from the review pass):
McpClient—initialize,initialized,tools/call,DELETE: four round-trips per call. The client is now created once after the child is ready and reused for discovery and every forward.almcp's HTTP transport evicts idle sessions (default 2 h) with a 404, which the server never dispatched, so that one case is retried once on a fresh session; any other transport failure drops the cached client and surfaces the error unchanged.AlMcpProxyStartupawaited the child launch, the 30 s readiness poll and tool discovery insideIHostedService.StartAsync, ahead of the SDK's stdio hosted service — so a slow or brokenalmcpblocked eveninitialize. It now starts in the background;initializeanswers in under a second.tools/listwaits up to 10 s for the child so hosts that list once still get the full set; past that it answers with the native tools and arms a one-shotnotifications/tools/list_changed(we already advertiselistChanged). Earlyal_*calls wait for readiness instead of failing.almcpwrites its banner,Port: Nand project-load progress to stdout; the proxy redirected only stderr, so the child inherited ours and 17 non-JSON lines landed in the middle of the MCP stream. Both streams are now captured and logged at Debug.FindFreePorthas to release the port beforealmcpcan bind it; if another process grabs it in between, the child exits and startup retries on a fresh port (up to three attempts). Connections go to127.0.0.1— the family the port was reserved on — instead of resolvinglocalhostper connect.Deleted —
AnalyzeTool,DiagnosticsRunner,DiagnosticResult,AnalyzerRegistry, and the five resolution/locator services above.Live bugs found while verifying
http://localhost:<port>/mcp/, butalmcpcallsMapMcp()with no pattern — its endpoint is the server root, and/mcp/404s. The readiness probe accepted any response including that 404, so startup reported success and the handshake failed afterwards. Endpoint corrected; readiness now rejects 404.almcpresolves analyzer dependencies only among the paths it is handed and never probes the analyzer's own directory. Passing just the cop DLLs turned every rule intoAD0001 FileNotFoundException— including Microsoft's own CodeCop, which needs its siblingAnalyzers.Common.dll. Dependencies are now read from assembly metadata and included.almcp's stdout was our stdout (see above) — invisible to lenient clients, fatal to strict ones.Also fixed:
ParseProxyOptionsshared onei + 1 < args.Lengthguard across every passthrough flag and unconditionally consumed the next arg, so--nolog/--noautheither vanished (when last) or swallowed the following flag as their value. Split intoalmcp's three real arities, in a testableProxyOptions.Parse. Three tool descriptions still claimed "ALCops analyzers are always included"; removed.Version resilience
Unchanged — dynamic registration means our code never references an MS tool name or schema.
ListToolsAsync()almcpdropped entirelyCLI args
--devtools-path <dir>— use this DevTools directory instead of probing--projects <dir>[;<dir>]— override project discovery; a non-project directory is scanned for projects beneath it--no-proxy— native tools only; retained deliberately, for when the agent already registersalmcpitself and would otherwise see theal_*tools twicealmcp's own args (--codeanalyzers,--rulesetpath,--packagecachepath,--noauth,--nolog, …) are forwarded and override anything discovered from the projectTest plan
dotnet build— 0 errors, 0 warnings (confirmsPackageDownload+HintPathresolves thetools/net10.0/any/layout)dotnet test— 68/68 pass. The code-fix guard assertions genuinely fire rather than passing vacuously;AlMcpProxyTests/AlMcpProxyStartupTestsrun against the realalmcpfrom the restored DevTools package (session reuse across calls, reconnect-and-retry after an out-of-bandDELETEof the session, non-blocking start, early call waits, stop during boot,list_changedarmed once) — they self-skip wherealmcpis not runnablegrepfor every removed symbol — no live references--no-proxy— tools list is exactlylist_rules,get_fixes,apply_fix,apply_fix_all;analyzeabsentsettings.jsonread, and every resolved analyzer/ruleset/package-cache path; the 16al_*tools appearinitialize→tools/list→tools/call):initializeanswered at ~0.9 s whilealmcpwas still booting;tools/listreturned 4 native + 16al_*; stdout carried JSON only (0 non-JSON lines, previously 17). With the list budget forced to 0: native-only list, then exactly onetools/list_changed, then the full list. With a stubalmcp.exethat dies:initialize/tools/liststill instant,al_*call returns "not available" in 18 msal_compile { onlyErrors: false }returnsLC0020alongside CodeCop'sAA0215/0218/0225/0247/0248and zeroAD0001→get_fixesreturns a fix →apply_fixmodifies the file on disk → recompile andLC0020is goneLC0020toNonehides it from bothal_compileandget_fixes, other rules unaffected — the specific guarantee the startup bridge exists to providedotnet pack— noMicrosoft.Dynamics.Nav.*.dll, noALCops.*Cop.dllin the nupkgNotes for review
ALCops.Analyzersmoves to the test project rather than being deleted outright. The fixtures testLC0020/AC0012; with nothing bundled they would find zero diagnostics and pass vacuously. Test-only keeps the package clean (verified above) and means the tests now exercise the real production path — spec →ExternalAnalyzerLoader→AnalyzerAssemblyLoadContext— which is better coverage than the built-in registry path it replaces.Microsoft.Dynamics.Nav.LanguageModelTools.AnalyzerReferenceResolveris public in the pinned floor version, but was rejected: it resolves throughCompilerPathUtilities.GetPathToAnalyzerFolder()(the running process's base directory), so it cannot honour--devtools-pathor our locator at all — and it would add a 4th version-drift surface to the three DLLs the CI matrix swaps.AnalyzerSpec.csstays.<devtools>/<tfm>entry inpackageCachePathsas a loss of system symbols. It was not: no.appships in the DevTools package or the AL extension, and the fixtures compile and fix without any.alpackages. The real gap next to it —al.packageCachePathbeing ignored — is what got fixed..github/copilot-instructions.mdtoAGENTS.md(imported byCLAUDE.md), so Copilot and Claude Code read the same file.Known, out of scope
apply_fix, anal_compilein the same session still reports the fixed diagnostic — the childalmcpserves its cached compilation (Recompilation of all app for every Analyze call #4). A fresh session is clean.almcplauncher (onlyalmcp.exe+almcp.dll, andDotnetToolSettings.xmldeclares onlyal), so off Windows the proxy is unavailable from the dotnet tool store untilBcToolsLocatorlearns to fall back todotnet almcp.dll. This is also why thealmcp-backed tests skip on the Ubuntu CI runner.ModelContextProtocol0.9.0-preview.2→1.0.0(the versionalmcp18.0 itself ships) would drop the MSB3277 suppression. Separate PR.🤖 Generated with Claude Code