Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 6 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ A plain `PackageReference` is impossible: as of 17.0 the `Microsoft.Dynamics.Bus
- `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.
At runtime `BcToolsLocator` finds the DLLs in the user's own toolchain. The DevTools themselves are never downloaded at runtime; only ALCops' own analyzers are provisioned from NuGet (see below).

## Architecture

Expand All @@ -48,13 +48,16 @@ It is deliberately **thin**: Microsoft's `almcp` already compiles, runs diagnost
- `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.
- `AlcopsAnalyzerProvisioner` — downloads ALCops' own analyzers from NuGet, matched to the installed DevTools TFM, and caches them under `~/.alcops/analyzers/`. `Task<string?> Ready` completes with the provisioned folder or `null`. Configured via `--alcops-analyzers` / `ALCOPS_ANALYZERS` / `ALCOPS_ANALYZERS_CACHE`.
- `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
### Analyzers are never bundled; ALCops' own analyzers are provisioned at runtime

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`.
Shipping pinned cop DLLs beside whatever `Nav.CodeAnalysis` the user installed is what caused `AD0001` / `MissingMethodException` (issue #10). Microsoft cops and third-party analyzers come solely from the project's own config and the DevTools directory. `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`.

ALCops' own analyzers are provisioned by `AlcopsAnalyzerProvisioner` at every startup: it detects the DevTools TFM, downloads the latest stable `ALCops.Analyzers` NuGet package (or uses a pinned/prerelease version per `--alcops-analyzers`), extracts the matching `lib/<tfm>/` folder, and caches the DLLs under `~/.alcops/analyzers/<tfm>/<version>/`. `ExternalAnalyzerLoader.ResolveDllPath` probes the provisioned folder first for `${analyzerFolder}ALCops.*.dll` specs. The DevTools themselves are never downloaded at runtime.

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.

Expand Down
26 changes: 23 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,28 @@ Pass `--no-proxy` to serve only the native tools. Use it when your agent already

## Analyzers

**Analyzers are not bundled.** The server loads exactly what your project configures via `al.codeAnalyzers` in `.vscode/settings.json` (AL-Go's `rulesetFile` and the `custom.ruleset.json` / `app.ruleset.json` conventions are honored too). That includes ALCops' cops, BC's standard cops (`${CodeCop}`, `${UICop}`, `${PerTenantExtensionCop}`, `${AppSourceCop}`), and any third-party analyzer.
**Microsoft cops and third-party analyzers are never bundled.** The server loads exactly what your project configures via `al.codeAnalyzers` in `.vscode/settings.json` BC's standard cops (`${CodeCop}`, `${UICop}`, `${PerTenantExtensionCop}`, `${AppSourceCop}`) and any third-party analyzer resolve from the DevTools directory. AL-Go's `rulesetFile` and the `custom.ruleset.json` / `app.ruleset.json` conventions are honored too.

This is deliberate: bundling pinned cop DLLs beside whatever `Nav.CodeAnalysis` you have installed is what produced `AD0001` / `MissingMethodException` failures ([#10](https://github.com/ALCops/mcp-server/issues/10)). Resolving both from your own toolchain makes that mismatch impossible.
This is deliberate: bundling pinned cop DLLs beside whatever `Nav.CodeAnalysis` you have installed is what produced `AD0001` / `MissingMethodException` failures ([#10](https://github.com/ALCops/mcp-server/issues/10)).

### ALCops analyzer provisioning

ALCops' own analyzers (`${analyzerFolder}ALCops.*.dll`) are provisioned automatically at every startup. The server detects the installed DevTools' target framework (e.g. `net10.0`), downloads the latest stable [ALCops.Analyzers](https://www.nuget.org/packages/ALCops.Analyzers) NuGet package, extracts the matching `lib/<tfm>/` folder, and caches the DLLs under `~/.alcops/analyzers/<tfm>/<version>/`. On subsequent starts a newer stable version is picked up automatically; older cached versions are left in place.

Configure with `--alcops-analyzers` or the `ALCOPS_ANALYZERS` environment variable:

| Value | Behaviour |
|-------|-----------|
| `latest` (default) | Download the latest stable release. |
| `prerelease` | Download the highest version including prereleases. |
| `<version>` (e.g. `1.2.0`) | Pin to a specific version (no index lookup). |
| `off` | Disable provisioning entirely. |

Set `ALCOPS_ANALYZERS_CACHE` to override the default cache directory (`~/.alcops/analyzers`).

When offline, the newest previously cached version for the target TFM is used with a warning. When no cache exists, the server starts without ALCops analyzers and logs a message with manual provisioning instructions.

The recommended `al.codeAnalyzers` configuration:

```json
{
Expand All @@ -86,7 +105,7 @@ The DevTools DLLs and `almcp` live in the same directory in both delivery channe
3. dotnet tool store (`~/.dotnet/tools/.store/…`) — highest version wins
4. AL Language VS Code extension `bin/` — highest version wins

If none match, the server exits with the install command rather than starting up degraded. Nothing is downloaded at runtime.
If none match, the server exits with the install command rather than starting up degraded. The DevTools themselves are never downloaded at runtime — only ALCops' own analyzers are provisioned from NuGet (see [Analyzers](#analyzers) above).

## CLI Options

Expand All @@ -95,6 +114,7 @@ If none match, the server exits with the install command rather than starting up
| `--devtools-path <dir>` | Use this BC DevTools directory instead of probing. |
| `--projects <dir>[;<dir>]` | Work on these projects instead of scanning down from the working directory for `app.json`. A directory that is not itself a project is scanned for projects beneath it; entries with none are ignored with a warning. |
| `--no-proxy` | Serve only the native tools; do not start `almcp`. |
| `--alcops-analyzers <mode>` | `latest` (default), `prerelease`, a pinned version, or `off`. See [Analyzers](#analyzers). |

Arguments `almcp` understands — `--codeanalyzers`, `--rulesetpath`, `--settingspath`, `--enablecodeanalysis`, `--enableexternalrulesets`, `--locale`, `--noauth`, `--nolog` and friends — are forwarded to the child process and override anything discovered from your project.

Expand Down
33 changes: 31 additions & 2 deletions src/ALCops.Mcp/McpHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,15 +35,44 @@ public static async Task RunAsync(string[] args, BcToolsLocator toolsLocator, Pr
builder.Services.AddSingleton<ProjectLoader>();
builder.Services.AddSingleton<ProjectSessionManager>();
builder.Services.AddSingleton<CodeFixRunner>();
builder.Services.AddSingleton<ExternalAnalyzerLoader>();
builder.Services.AddSingleton(sp =>
{
var provisioner = sp.GetRequiredService<AlcopsAnalyzerProvisioner>();
Func<string?> provisionedFolder = () =>
provisioner.Ready is { IsCompletedSuccessfully: true, Result: not null }
? provisioner.Ready.Result
: null;
return new ExternalAnalyzerLoader(
sp.GetRequiredService<BcToolsLocator>(),
provisionedFolder);
});
builder.Services.AddSingleton<RulesetLoader>();
builder.Services.AddSingleton<ProjectAnalyzerResolver>();
builder.Services.AddSingleton(sp =>
new ProjectAnalyzerResolver(
sp.GetRequiredService<ExternalAnalyzerLoader>(),
sp.GetRequiredService<RulesetLoader>(),
sp.GetRequiredService<AlcopsAnalyzerProvisioner>()));

// ALCops analyzer provisioner: downloads ALCops' own analyzers from NuGet, matched to the
// installed DevTools TFM. Runs under --no-proxy too — the native fix tools need them.
var analyzersOption = AlcopsAnalyzersOption.Parse(
proxyOptions.AlcopsAnalyzers
?? Environment.GetEnvironmentVariable("ALCOPS_ANALYZERS"));
builder.Services.AddSingleton(sp =>
new AlcopsAnalyzerProvisioner(
sp.GetRequiredService<BcToolsLocator>(),
analyzersOption,
null,
null,
sp.GetRequiredService<ILogger<AlcopsAnalyzerProvisioner>>()));
builder.Services.AddHostedService<AlcopsAnalyzerProvisionerStartup>();

// Registered even with --no-proxy: list_rules falls back to the discovered project too.
builder.Services.AddSingleton(sp =>
new WorkspaceStartupResolver(
sp.GetRequiredService<ProjectAnalyzerResolver>(),
sp.GetRequiredService<ExternalAnalyzerLoader>(),
sp.GetRequiredService<AlcopsAnalyzerProvisioner>(),
sp.GetRequiredService<ILogger<WorkspaceStartupResolver>>(),
proxyOptions.Projects));

Expand Down
4 changes: 3 additions & 1 deletion src/ALCops.Mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@ Add to your `.mcp.json` (Claude Code) or `claude_desktop_config.json` (Claude De

## Analyzers

Analyzers are **not bundled**. The server loads exactly what your project configures via `al.codeAnalyzers` in `.vscode/settings.json` — ALCops' cops, BC's standard cops (`${CodeCop}`, `${UICop}`, `${PerTenantExtensionCop}`, `${AppSourceCop}`), or any third-party analyzer. AL-Go's `rulesetFile` and the `custom.ruleset.json` / `app.ruleset.json` conventions are honored too, and the same configuration is handed to the child `almcp` so `al_compile` and `get_fixes` agree about which rules run and which are suppressed.
Microsoft cops and third-party analyzers are **not bundled** — the server loads exactly what your project configures via `al.codeAnalyzers` in `.vscode/settings.json`. AL-Go's `rulesetFile` and the `custom.ruleset.json` / `app.ruleset.json` conventions are honored too, and the same configuration is handed to the child `almcp` so `al_compile` and `get_fixes` agree about which rules run and which are suppressed.

ALCops' own analyzers are provisioned automatically at every startup: the server detects the installed DevTools target framework, downloads the matching `ALCops.Analyzers` NuGet package, and caches it under `~/.alcops/analyzers/`. Configure with `--alcops-analyzers` (`latest` | `prerelease` | `<version>` | `off`) or the `ALCOPS_ANALYZERS` environment variable.

Browse the ALCops rules reference at [alcops.dev/docs/analyzers](https://alcops.dev/docs/analyzers/).

Expand Down
14 changes: 4 additions & 10 deletions src/ALCops.Mcp/Services/AlMcpProxy.cs
Original file line number Diff line number Diff line change
Expand Up @@ -93,10 +93,12 @@ public async Task StartAsync(CancellationToken cancellationToken)

private async Task StartCoreAsync(CancellationToken cancellationToken)
{
var resolverArgs = await _workspaceResolver.BuildAlMcpArgsAsync(_passthroughArgs);

for (var attempt = 1; ; attempt++)
{
_port = FindFreePort();
LaunchChild();
LaunchChild(["--port", _port.ToString(), .. resolverArgs]);

try
{
Expand All @@ -122,9 +124,8 @@ private async Task StartCoreAsync(CancellationToken cancellationToken)
_logger.LogInformation("Discovered {Count} tools from almcp", _cachedTools.Count);
}

private void LaunchChild()
private void LaunchChild(string[] args)
{
var args = BuildChildArgs();
_logger.LogInformation("Starting almcp on port {Port}: {Path} {Args}", _port, _almcpPath, string.Join(' ', args));

// Both streams must be captured. In HTTP mode almcp writes its banner, "Port: N" and the
Expand Down Expand Up @@ -352,13 +353,6 @@ private async Task DisposeClientAsync(McpClient client)
Content = [new TextContentBlock { Text = message }],
};

private string[] BuildChildArgs()
{
// The workspace resolver supplies --projects/--codeanalyzers/--rulesetpath from the project's
// own config; anything the user passed through on our CLI overrides it.
return ["--port", _port.ToString(), .. _workspaceResolver.BuildAlMcpArgs(_passthroughArgs)];
}

private static int FindFreePort()
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
Expand Down
Loading
Loading