Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
622fb57
feat: add AlMcpLocator to discover almcp executable
Arthurvdv Aug 24, 2026
21fe3b7
feat: add AlMcpProxy service for child process and HTTP forwarding
Arthurvdv Aug 24, 2026
9cb54a4
feat: wire dynamic tool registration and proxy CLI args
Arthurvdv Aug 24, 2026
b811b73
build: resolve BC DevTools via PackageDownload instead of a vendored …
Arthurvdv Aug 24, 2026
57e7dd3
refactor!: narrow the server to code fixes plus an almcp proxy
Arthurvdv Aug 24, 2026
8cfca92
test: rework tests for project-configured analyzers
Arthurvdv Aug 24, 2026
2564cf0
docs: update for the proxy topology and new prerequisites
Arthurvdv Aug 24, 2026
f39a4e1
build: raise the BC DevTools floor to 18.0.41.39415 and compile again…
Arthurvdv Sep 11, 2026
f93430b
perf: reuse one MCP client for all forwarded almcp calls
Arthurvdv Sep 11, 2026
78a1c24
test: run AlMcpProxy against the real almcp from the DevTools package
Arthurvdv Sep 11, 2026
304e70d
chore: migrate agent instructions from Copilot to Claude Code
Arthurvdv Sep 11, 2026
04c9f45
fix: start almcp in the background so the stdio server answers immedi…
Arthurvdv Sep 11, 2026
81f98dd
fix: keep almcp's stdout off the MCP channel
Arthurvdv Sep 11, 2026
e73006b
feat: bridge al.packageCachePath to the in-process loader and to almcp
Arthurvdv Sep 12, 2026
752305b
fix: resolve --projects entries like the cwd, and drop stale analyzer…
Arthurvdv Sep 12, 2026
fcdde95
fix: connect to almcp over 127.0.0.1 and retry a colliding port
Arthurvdv Sep 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"permissions": {
"allow": [
"Bash(dotnet build:*)",
"Bash(dotnet test:*)",
"Bash(dotnet pack:*)",
"Bash(dotnet restore:*)",
"PowerShell(dotnet build:*)",
"PowerShell(dotnet test:*)",
"PowerShell(dotnet pack:*)",
"PowerShell(dotnet restore:*)"
]
}
}
2 changes: 1 addition & 1 deletion .github/ISSUE_TEMPLATE/bug_report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ body:
attributes:
label: AL Language extension version
description: The server picks up the BC Development Tools from the AL Language VS Code extension, if installed.
placeholder: e.g., 17.0.1856043
placeholder: e.g., 18.0.41.39415
validations:
required: false

Expand Down
57 changes: 0 additions & 57 deletions .github/copilot-instructions.md

This file was deleted.

2 changes: 1 addition & 1 deletion .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
version: 2
updates:
- package-ecosystem: nuget
directory: /src/ALCops.Mcp
directory: /tests/ALCops.Mcp.Tests
schedule:
interval: weekly
allow:
Expand Down
21 changes: 5 additions & 16 deletions .github/workflows/build-and-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,22 +44,10 @@ jobs:
with:
dotnet-version: 10.0.x

- name: Setup BC DevTools
run: |
PACKAGE="microsoft.dynamics.businesscentral.development.tools"
VERSION="${{ needs.build-and-test.outputs.lowest-version }}"
echo "BC DevTools version: ${VERSION} (lowest, matching test build)"
mkdir -p Microsoft.Dynamics.BusinessCentral.Development.Tools/net8.0
curl -sL "https://api.nuget.org/v3-flatcontainer/${PACKAGE}/${VERSION}/${PACKAGE}.${VERSION}.nupkg" -o /tmp/bcdevtools.nupkg
# Package layout varies across versions; extract from all known paths.
unzip -joq /tmp/bcdevtools.nupkg "tools/net8.0/any/*.dll" -d Microsoft.Dynamics.BusinessCentral.Development.Tools/net8.0/ 2>/dev/null || true
unzip -joq /tmp/bcdevtools.nupkg "tools/net10.0/any/*.dll" -d Microsoft.Dynamics.BusinessCentral.Development.Tools/net8.0/ 2>/dev/null || true
unzip -joq /tmp/bcdevtools.nupkg "lib/net8.0/*.dll" -d Microsoft.Dynamics.BusinessCentral.Development.Tools/net8.0/ 2>/dev/null || true
unzip -joq /tmp/bcdevtools.nupkg "lib/net10.0/*.dll" -d Microsoft.Dynamics.BusinessCentral.Development.Tools/net8.0/ 2>/dev/null || true
ls Microsoft.Dynamics.BusinessCentral.Development.Tools/net8.0/Microsoft.Dynamics.Nav.CodeAnalysis.dll

- name: Build
run: dotnet build --configuration Release
# BC DevTools come from restore (PackageDownload on the pinned version); only the version
# is supplied here, matching the version the test build compiled against.
- name: Build against BC DevTools v${{ needs.build-and-test.outputs.lowest-version }}
run: dotnet build --configuration Release -p:BcDevToolsVersion=${{ needs.build-and-test.outputs.lowest-version }}

# Git Version
- name: GitVersion - Setup
Expand All @@ -77,6 +65,7 @@ jobs:
dotnet pack ./src/ALCops.Mcp/ALCops.Mcp.csproj
--configuration Release
--output ./artifacts
/p:BcDevToolsVersion=${{ needs.build-and-test.outputs.lowest-version }}
/p:ContinuousIntegrationBuild=true
/p:EmbedUntrackedSources=true
/p:RepositoryType=git
Expand Down
26 changes: 7 additions & 19 deletions .github/workflows/build-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ jobs:
data = json.load(sys.stdin)
all_versions = data['versions']

# Minimum supported major version (v16 and below are not supported)
MIN_MAJOR = 17
# Minimum supported major version (v17 and below are not supported)
MIN_MAJOR = 18

def parse_base(v):
return tuple(int(p) for p in v.split('-')[0].split('.'))
Expand Down Expand Up @@ -92,23 +92,11 @@ jobs:
with:
dotnet-version: 10.0.x

- name: Setup BC DevTools
run: |
PACKAGE="microsoft.dynamics.businesscentral.development.tools"
VERSION="${{ needs.setup.outputs.lowest-version }}"
echo "Downloading BC DevTools v${VERSION} (lowest) for compilation..."
mkdir -p Microsoft.Dynamics.BusinessCentral.Development.Tools/net8.0
curl -sL "https://api.nuget.org/v3-flatcontainer/${PACKAGE}/${VERSION}/${PACKAGE}.${VERSION}.nupkg" -o /tmp/bcdevtools.nupkg
# Package layout varies across versions; extract from all known paths.
# Later extractions overwrite earlier ones (-o), so net10.0 wins when both exist.
unzip -joq /tmp/bcdevtools.nupkg "tools/net8.0/any/*.dll" -d Microsoft.Dynamics.BusinessCentral.Development.Tools/net8.0/ 2>/dev/null || true
unzip -joq /tmp/bcdevtools.nupkg "tools/net10.0/any/*.dll" -d Microsoft.Dynamics.BusinessCentral.Development.Tools/net8.0/ 2>/dev/null || true
unzip -joq /tmp/bcdevtools.nupkg "lib/net8.0/*.dll" -d Microsoft.Dynamics.BusinessCentral.Development.Tools/net8.0/ 2>/dev/null || true
unzip -joq /tmp/bcdevtools.nupkg "lib/net10.0/*.dll" -d Microsoft.Dynamics.BusinessCentral.Development.Tools/net8.0/ 2>/dev/null || true
ls Microsoft.Dynamics.BusinessCentral.Development.Tools/net8.0/Microsoft.Dynamics.Nav.CodeAnalysis.dll

- name: Build
run: dotnet build --configuration Release
# BC DevTools are acquired by restore itself: the projects declare a PackageDownload on the
# pinned version and reference the DLLs out of the global packages folder. Only the version
# needs supplying here, so the version matrix stays in this workflow's hands.
- name: Build against BC DevTools v${{ needs.setup.outputs.lowest-version }} (lowest stable)
run: dotnet build --configuration Release -p:BcDevToolsVersion=${{ needs.setup.outputs.lowest-version }}

- name: Upload test artifacts
uses: actions/upload-artifact@v4
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -489,3 +489,4 @@ Microsoft.Dynamics.BusinessCentral.Development.Tools/
# Pack output
artifacts/
.serena/
.claude/settings.local.json
7 changes: 7 additions & 0 deletions .mcp.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"mcpServers": {
"alcops": {
"command": "alcops-mcp"
}
}
}
74 changes: 74 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# ALCops MCP Server — Agent Instructions

## Build

```sh
# Build (BC DevTools come from restore, see below)
dotnet build --configuration Release

# Run tests
dotnet test --configuration Release

# Pack as .NET global tool
dotnet pack src/ALCops.Mcp/ALCops.Mcp.csproj --configuration Release --output ./artifacts
```

There are no linters in this repository.

### BC DevTools dependency

The project compiles against proprietary Microsoft BC Development Tools DLLs (`Microsoft.Dynamics.Nav.CodeAnalysis`, `.Workspaces`, `.Analyzers.Common`).

A plain `PackageReference` is impossible: as of 17.0 the `Microsoft.Dynamics.BusinessCentral.Development.Tools` package is `DotnetTool` + `Template` only, with all payload under `tools/<tfm>/any/`, and NuGet rejects referencing a `DotnetTool` package. Instead both csproj files declare a `<PackageDownload>` on a pinned version — which restores the nupkg into the global packages folder without referencing it — and point three `<Reference>` items at `$(NuGetPackageRoot)…/tools/$(BcToolsTfm)/any/`.

- `$(BcDevToolsVersion)` — the compile floor, deliberately the *lowest* supported stable release. Compiling against the oldest SDK and running against newer ones is what makes forward compatibility hold; CI overrides this property to run its version matrix.
- `$(BcToolsTfm)` — `net10.0` by default, matching our own TFM (18.x ships both `net8.0` and `net10.0` payloads; the 17.x line shipped net8.0 only).
- `src` sets `<Private>false</Private>`, so the proprietary DLLs never enter the build output and therefore never enter the published package. This is the redistribution guard.
- `tests` sets `<Private>true</Private>` on purpose: the CI compatibility matrix hot-swaps those three DLLs in the prebuilt test binary to run the same tests against every supported SDK version.

At runtime `BcToolsLocator` finds the DLLs in the user's own toolchain. Nothing is downloaded at runtime.

## Architecture

An MCP (Model Context Protocol) server packaged as a .NET 10 global tool (`alcops-mcp`), served over stdio JSON-RPC.

It is deliberately **thin**: Microsoft's `almcp` already compiles, runs diagnostics, resolves symbols, publishes, runs tests and handles translations, so all of that is proxied. What `almcp` has no capability for at all — code fixes (its LSP mode advertises `CodeActionProvider = false`) and rule enumeration — is what this server implements natively. Adding anything here that `almcp` already does is a regression of that design.

### Startup sequence (order matters)

1. `Program.cs` calls `BcToolsLocator.ResolveAndRegister()` to find the tools directory and register an `AssemblyLoadContext` resolver. This **must** happen before any BC types are JIT-compiled — the DLLs are not in the output directory, so nothing can resolve them before this runs.
2. `McpHost.RunAsync()` is marked `[NoInlining]` to enforce that ordering, then builds the host, registers DI services, and starts the MCP stdio transport.
3. `AlMcpProxyStartup` (an `IHostedService`) launches `almcp` as a child process on a free localhost port and caches its tool list — **on a background task**, so the stdio server and the four native tools are up immediately no matter how long the child takes. `AlMcpProxy.Ready` is the signal everything else waits on: `tools/list` gives it 10s and otherwise answers with the native tools plus a one-shot `notifications/tools/list_changed`; an `al_*` call that arrives early parks on `Ready` instead of failing.

### Key layers

- **Tools/** — MCP tool endpoints, annotated `[McpServerToolType]` / `[McpServerTool]`, auto-discovered via `WithToolsFromAssembly()`. Four native tools: `list_rules`, `get_fixes`, `apply_fix`, `apply_fix_all`. The proxied `al_*` tools are served by the dynamic list/call handlers in `McpHost`, not by classes here.
- **Services/**, all singletons registered in `McpHost`:
- `BcToolsLocator` — the single runtime lookup. Finds the one directory holding both `Microsoft.Dynamics.Nav.*.dll` and `almcp[.exe]` (they ship side by side in both delivery channels). Probe order: `--devtools-path` → `BCDEVELOPMENTTOOLSPATH` → dotnet tool store → AL VS Code extension `bin/` → hard error naming the install command.
- `WorkspaceStartupResolver` — discovers AL projects (mirrors `almcp`'s own `DiscoverProjectPaths`: downward scan for `app.json`, depth 4, standard exclusions) and composes the child `almcp`'s `--projects` / `--codeanalyzers` / `--rulesetpath` / `--packagecachepath` args. `almcp` in MCP mode never reads `.vscode/settings.json` and has no per-call analyzer, ruleset or package-cache parameter, so this bridge at launch is the only thing keeping `al_compile` and our fix tools in agreement (`ProjectLoader` reads the same `al.packageCachePath` for the in-process compilation).
- `AlMcpProxy` — child process lifecycle plus generic tool forwarding over a single long-lived MCP client that reconnects on session expiry. `ForwardAsync` is a passthrough with **no per-tool argument rewriting**; configuration is conveyed at launch instead.
- `ProjectAnalyzerResolver` — reads `al.codeAnalyzers` and the ruleset (`.vscode/settings.json`, `.AL-Go/settings.json`, convention-named files) and builds an `AnalyzerSet`. Nothing is built in.
- `ExternalAnalyzerLoader` — loads analyzer DLLs through `AnalyzerAssemblyLoadContext`, which resolves shared types by simple name from the default context. That type sharing is what makes `typeof(DiagnosticAnalyzer).IsAssignableFrom` work, and therefore what makes in-process code fixes possible at all.
- `ProjectSessionManager` / `ProjectLoader` — caches AL project workspaces keyed by path; `GetOrLoadProjectAsync` is the entry point tools use.
- **Models/** — record types for tool return values, serialized with `JsonDefaults.Options` (camelCase, not indented).

### Analyzers are never bundled

Shipping pinned cop DLLs beside whatever `Nav.CodeAnalysis` the user installed is what caused `AD0001` / `MissingMethodException` (issue #10). Analyzers come solely from the project's own config. `ALCops.Analyzers` is referenced by the **test project only**, so the fixtures have real cops with real code fixes to exercise; it must never move back to `src`.

When passing analyzers to the child `almcp`, their sibling dependencies must travel with them (`ALCops.Common.dll`, `Microsoft.Dynamics.Nav.Analyzers.Common.dll`): `almcp` resolves analyzer dependencies only among the paths it was given and does not probe the analyzer's directory. A missing one turns every rule in that assembly into an `AD0001` instead of a diagnostic.

### Tool patterns

- All tool methods are `static async Task<string>`, receiving DI services as parameters.
- Tools return JSON-serialized results. Errors are caught and returned as `{ error, message }` JSON, not thrown.
- `apply_fix` writes to disk and reloads the project session; `apply_fix_all` does the same across every occurrence of a rule (unless `dryRun`); the other two are read-only.
- `al_compile` defaults to `onlyErrors: true` while nearly every ALCops rule is a warning — callers must pass `onlyErrors: false`. This is documented rather than patched, because `ForwardAsync` stays a generic passthrough.

## Conventions

- **Target framework**: .NET 10, C# latest, nullable enabled, implicit usings.
- **Namespaces**: `ALCops.Mcp.Tools`, `ALCops.Mcp.Services`, `ALCops.Mcp.Models`. File-scoped namespaces throughout.
- **JSON serialization**: Use `JsonDefaults.Options` (camelCase) for tool responses. Use `JsonDocumentOptions` with `CommentHandling.Skip` and `AllowTrailingCommas` when parsing user-facing JSON files (settings.json, rulesets).
- **Logging**: All diagnostic output goes to stderr. Stdout is reserved for the MCP JSON-RPC protocol. Startup logs which tools directory won, which `settings.json` was read, and every resolved analyzer and ruleset path — a silently wrong working-directory guess is the failure mode this exists to make visible.
- **Versioning**: GitVersion with GitHubFlow. Version is determined from git history, not hardcoded. Branches: `main` produces alpha prereleases, `release/**` branches produce stable versions.
6 changes: 6 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
@AGENTS.md

## Claude Code notes

- Shell commands run through the PowerShell tool on Windows; the `dotnet build/test/pack/restore` commands in AGENTS.md are pre-allowed in `.claude/settings.json`.
- `.mcp.json` registers the published `alcops-mcp` tool (the same snippet the README gives users). It needs `dotnet tool install -g ALCops.Mcp` plus BC DevTools installed; it is not the dev build from `src/`.
Loading
Loading