Category Archives: MCP Servers

Why My AL MCP Server Could Not Publish or Run Tests

Introduction

Most of my day-to-day work is building AL extensions for Microsoft Dynamics 365 Business Central, and for the last while I have been doing a growing part of that work with an AI coding agent sitting next to me in the editor. That only pays off if the agent can actually do things rather than just talk about them, and in practice that means MCP servers.

MCP (Model Context Protocol) is the standard way an agent gets access to real tools. Instead of the agent guessing whether my code compiles, it calls a tool that compiles it and reads the diagnostics back. Instead of me pasting error messages into a chat window, the tool returns them directly. The difference between an agent that can only suggest code and one that can compile, publish and test it is roughly the difference between a rubber duck and a colleague.

For AL development, the relevant one is the AL MCP server that ships inside the AL Language extension for Business Central. It exposes the operations that make up my normal inner loop:

  • al_compile and al_build — validate the code, generate the .app package
  • al_getdiagnostics — read errors and warnings back without recompiling
  • al_downloadsymbols and al_getpackagedependencies — manage the symbol cache and inspect app.json dependencies
  • al_symbolsearch — find tables, codeunits, pages, fields across the project and its dependencies
  • al_publish — push the extension to a Business Central server or container
  • al_run_tests — run a test codeunit and get pass/fail results

That last pair is where the real value is. Compiling is nice, but a change to a billing calculation is not verified until the tests have actually run against a server. If al_publish and al_run_tests work, the agent can close the loop on its own: build, deploy, test, read the failures, fix, repeat. If they do not, I am back to alt-tabbing into the IDE for every iteration, and most of the benefit evaporates.

On my machine, those two were exactly the ones that did not work.

Issue

The symptom was oddly specific: everything local worked, and everything that touched the server failed.

These were all fine:

al_getpackagedependencies  ->  OK, full dependency list returned
al_symbolsearch            ->  OK, symbols found across dependencies
al_compile                 ->  OK, diagnostics returned
al_build                   ->  OK, .app package generated

And then, calling al_run_tests against my on-premise development server with user name and password authentication:

{
  "succeeded": false,
  "message": "Exception of type 'Microsoft.Dynamics.Nav.Deployment.Authentication.UserNotAuthenticatedException' was thrown.",
  "errorDetails": {
    "code": "UnknownError",
    "description": "An unknown error occurred",
    "possibleCauses": [
      "Extension internal error",
      "Unexpected system state"
    ],
    "suggestedActions": [
      "Try the operation again",
      "Restart VS Code",
      "Check AL extension output for details"
    ]
  }
}

al_publish failed the same way. The buried UserNotAuthenticatedException is the only useful part of that payload; everything around it is generic filler. “Extension internal error”, “Unexpected system state” and “Restart VS Code” point at three things that were not the problem, which is worse than saying nothing at all.

What made it genuinely confusing is that authentication was never prompted for and never failed anywhere else. Publishing the same extension to the same server from the IDE with F5 worked perfectly, and had worked for months. So the credentials clearly existed and were clearly valid. The MCP server just could not see them.

Reason

The cause turned out to be that I had two different builds of the same tool installed, and the MCP server was launched with the wrong one.

The AL MCP server is started by running altool launchmcpserver with a list of project folders. On a typical development machine, altool exists in two places:

  1. The dotnet global tool. Installing the Microsoft.Dynamics.BusinessCentral.Development.Tools NuGet package puts al.exe on the PATH, at something like C:\Users\dev\.dotnet\tools\al.exe.
  2. The binary inside the AL Language extension, at C:\Users\dev\.vscode\extensions\ms-dynamics-smb.al-17.0.1234567\bin\win32\altool.exe.

Both accept launchmcpserver. Both report the same version number. Both start a working MCP server that answers every tool call. My configuration used the convenient one:

"al-mcp": {
  "type": "stdio",
  "command": "al",
  "args": ["launchmcpserver", "C:/git_repos/MyProduct/MyApp", "..."]
}

The difference between the two only shows up when a tool needs on-premise credentials. Business Central’s UserPassword authentication for a development server is cached by the AL extension inside its own installation folder:

C:\Users\dev\.vscode\extensions\ms-dynamics-smb.al-17.0.1234567\bin\win32\UserPasswordCache.dat

Not in Windows Credential Manager. Not in the IDE’s secret storage. In a file sitting next to altool.exe, in a folder whose name contains the extension version. I confirmed this by checking timestamps: the cache file predated my session by weeks — it was created by an ordinary manual publish from the IDE — and its last-access time updated to the exact moment a working MCP call ran. The MCP server does not create that cache; it reads a cache the extension created earlier.

Which explains everything. The dotnet global tool lives in a completely different directory tree and has no such file anywhere in its payload. So it starts a perfectly healthy MCP server that can parse, compile and build all day long — and the moment a tool needs to authenticate against a server, it has no credentials, cannot prompt for any (there is no interactive console in an MCP call), and throws UserNotAuthenticatedException.

Two things I tried along the way that are worth warning about:

Environment variables do not help. My original configuration carried this, presumably from an earlier attempt at exactly this problem:

"env": {
  "BC_SERVER_USERNAME": "admin",
  "BC_SERVER_PASSWORD": "..."
}

altool has no such mechanism. Those variables did nothing at all except keep a plaintext password in a configuration file, which is a small security problem masquerading as a fix. I removed them.

The obvious fix has its own trap. Pointing command straight at the extension’s altool.exe works, but look at that path again — it contains 17.0.1234567. Every time the AL extension updates, it installs into a new versioned folder and the old one goes away. The configured path stops existing, the MCP server silently fails to launch, and there is no error until you try to use a tool. Worse, the credential cache lives in that same doomed folder, so an extension update loses the cached credentials too.

So the two candidates each solve half the problem: the dotnet tool has a stable path but no credentials, and the extension binary has the credentials but an unstable path.

Solution

The fix is a small launcher script that resolves the newest installed extension at launch time, so the configuration can point at a path that never changes while still running the binary that owns the credential cache. It also copies the cache forward when it finds a fresh extension folder without one.

@echo off
rem Stable launcher for the AL extension's altool.exe.
rem Diagnostics go to stderr only - stdout is the MCP pipe.
setlocal enabledelayedexpansion

set "EXTROOT=%USERPROFILE%\.vscode\extensions"
set "NEWDIR="
set "OLDCACHE="

rem Newest extension folder first; first one with a usable altool.exe wins.
for /f "delims=" %%D in ('dir /b /ad /o-d "%EXTROOT%\ms-dynamics-smb.al-*" 2^>nul') do (
    if exist "%EXTROOT%\%%D\bin\win32\altool.exe" (
        if not defined NEWDIR set "NEWDIR=%EXTROOT%\%%D\bin\win32"
    )
)

if not defined NEWDIR (
    echo [al-mcp shim] No altool.exe found under "%EXTROOT%\ms-dynamics-smb.al-*". 1>&2
    exit /b 1
)

rem An extension update creates a folder without the credential cache.
rem Carry the newest existing one forward so on-prem auth survives updates.
if not exist "%NEWDIR%\UserPasswordCache.dat" (
    for /f "delims=" %%D in ('dir /b /ad /o-d "%EXTROOT%\ms-dynamics-smb.al-*" 2^>nul') do (
        if exist "%EXTROOT%\%%D\bin\win32\UserPasswordCache.dat" (
            if not defined OLDCACHE set "OLDCACHE=%EXTROOT%\%%D\bin\win32\UserPasswordCache.dat"
        )
    )
    if defined OLDCACHE copy /y "!OLDCACHE!" "%NEWDIR%\UserPasswordCache.dat" >nul 2>&1
)

"%NEWDIR%\altool.exe" %*
exit /b %ERRORLEVEL%

Two details in there matter more than they look:

Keep the script’s own output on stderr. Its stdout is the JSON-RPC pipe between the agent and the MCP server. One stray echo on stdout corrupts the protocol stream.

Sort by write time, not by name. Version folders sorted as strings will eventually order wrongly; newest-modified-first is what you actually want.

The configuration then invokes the script through cmd.exe:

"al-mcp": {
  "type": "stdio",
  "command": "cmd.exe",
  "args": [
    "/c", "C:\\Users\\dev\\bin\\al-mcp.cmd",
    "launchmcpserver",
    "C:/git_repos/MyProduct/MyApp",
    "C:/git_repos/MyProduct/MyAppTest",
    "--transport", "stdio",
    "--packagecachepath", "C:/git_repos/MyProduct/.alpackages"
  ],
  "env": {}
}

The cmd.exe /c wrapper is deliberate. Node cannot spawn a .cmd file without a shell, and cmd.exe is a dumb, byte-faithful pass-through for stdio — unlike a PowerShell wrapper, which can helpfully mangle encoding and line endings on a JSON-RPC stream.

After restarting the editor, I confirmed the running process was the right binary:

Get-CimInstance Win32_Process -Filter "Name='altool.exe'" |
    Select-Object -ExpandProperty ExecutablePath

C:\Users\dev\.vscode\extensions\ms-dynamics-smb.al-17.0.1234567\bin\win32\altool.exe

And then the call that had been failing:

al_run_tests  codeunitId=50100
              serverUrl=http://bc-dev  serverInstance=BC
              authentication=UserPassword  tenant=default

Using user name and password authentication. User name used is: 'admin'.
Sending request to http://bc-dev:7049/BC/dev/metadata?tenant=default
Test hub connected.
Test run completed: 2 passed, 0 failed, 0 skipped.

No prompt, no credentials in the call, no plaintext password in any configuration file. The cached credential that the IDE created weeks earlier was simply read by the binary that owns it. With that working, a full run went through end to end — build the dependency chain, publish to the development server, and execute the whole test suite: 36 test codeunits, 819 passing.

Two smaller things came out of the same exercise and are worth knowing if you set this up yourself. First, the AL MCP server does not read the global launch configuration from user settings, only a workspace-level launch.json; with no launch.json in the repository it logs No AL launch configuration found in launch.json and falls back to whatever the tool call passed, so I now pass serverUrl, serverInstance, authentication and tenant explicitly every time. Second, if the extension ever updates and the carried-forward credential cache does not decrypt, the recovery is one manual publish from the IDE to reseed it — an MCP call cannot answer an interactive credential prompt, so that step stays manual by design.

The broader lesson I am taking from this: when a set of tools splits cleanly into “works” and “does not work” along the line of needs credentials, stop reading the error message and start asking which binary is actually running. Two identical version numbers on two different paths cost me more time than the fix did.