The MSBuild API and MSBuild Locator

This section documents MSBuild 18.9, the .NET SDK 10.0.402 (with its dotnet CLI) and NuGet 7.9, as published in the MSBuild, .NET and NuGet documentation on learn.microsoft.com, which is the reference these pages are written and verified against. Material about .NET 11 / NuGet 8.0 is still in preview and is always flagged as such — never presented as baseline.

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The C# Reference bibliography lists the reference material consulted while preparing these pages.

Everything dotnet build does is available as a .NET API. Tools that inspect solutions, generate code from project metadata, run custom build orchestration or analyse a repository’s dependency graph load projects through the Microsoft.Build assemblies instead of parsing XML themselves, and get exactly the values MSBuild would compute — imports, conditions and SDK defaults included. This page builds a small Ledgerline tool with MSBuild 18.9 (.NET SDK 10.0.402), Microsoft.Build 18.9.6 and Microsoft.Build.Locator 1.11.2.

Packages and namespaces

Package Contents

Microsoft.Build

Microsoft.Build.Evaluation (Project, ProjectCollection), Microsoft.Build.Execution (BuildManager, ProjectInstance), Microsoft.Build.Construction (the XML object model), Microsoft.Build.Graph

Microsoft.Build.Framework

Shared interfaces: ITask, ILogger, the build event types

Microsoft.Build.Utilities.Core

Base classes for tasks and loggers (Task, ToolTask, Logger)

Microsoft.Build.Tasks.Core

The built-in tasks

Microsoft.Build.Runtime

A complete MSBuild to ship with an application — rarely the right choice, because evaluating real projects also needs the SDKs and compilers

Microsoft.Build.Locator

Finds an installed MSBuild (a .NET SDK, Visual Studio or Build Tools) and loads it

An application should not ship its own copy of MSBuild. It compiles against the Microsoft.Build packages and, at run time, loads the MSBuild of an installed .NET SDK or Visual Studio, so that projects resolve the same SDKs, imports and tasks as a command-line build. Microsoft.Build.Locator does the loading.

Source: MSBuild API.

A project-inspection tool

The tool references both packages. ExcludeAssets="runtime" keeps Microsoft.Build.dll out of the output folder; PrivateAssets="all" keeps the reference from flowing to anything that references the tool. The Locator package itself is referenced normally.

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.Build.Locator" Version="1.11.2" />
    <!-- Compile against MSBuild, but load the SDK's own copy at run time. -->
    <PackageReference Include="Microsoft.Build" Version="18.9.6" ExcludeAssets="runtime" PrivateAssets="all" />
  </ItemGroup>
</Project>

The compile-time Microsoft.Build version must not be newer than the MSBuild the tool will load: 18.9.6 is the newest 18.9 package, matching MSBuild 18.9.15 in SDK 10.0.402.

using Microsoft.Build.Locator;

// Must run before any Microsoft.Build type is loaded.
VisualStudioInstance instance = MSBuildLocator.RegisterDefaults();
Console.WriteLine($"MSBuild {instance.Version} from {instance.MSBuildPath}");

if (args.Length > 1 && args[0] == "build")
{
    return Builder.Run(args[1]);
}

Inspector.Run(args.Length > 0 ? args[0] : Directory.GetCurrentDirectory());
return 0;

static class Inspector
{
    // Kept in a separate method so the JIT loads Microsoft.Build only after registration.
    public static void Run(string root)
    {
        using var collection = new Microsoft.Build.Evaluation.ProjectCollection();
        foreach (string path in Directory.EnumerateFiles(root, "*.csproj", SearchOption.AllDirectories).Order())
        {
            var project = collection.LoadProject(path);
            string tfms = project.GetPropertyValue("TargetFrameworks");
            if (tfms.Length == 0) tfms = project.GetPropertyValue("TargetFramework");
            Console.WriteLine($"{Path.GetRelativePath(root, path),-55} {tfms}");
        }
    }
}
dotnet run --project LedgerInspect -- ~/ledgerline
MSBuild 10.0.402 from ~/.dotnet/sdk/10.0.402
samples/Ledgerline.Report/Ledgerline.Report.csproj      net10.0
src/Ledgerline.Build/Ledgerline.Build.csproj            netstandard2.0
src/Ledgerline.Cli/Ledgerline.Cli.csproj                net10.0
src/Ledgerline.Templates/Ledgerline.Templates.csproj    net10.0
src/Ledgerline.Templates/templates/ledgerline-app/LedgerlineApp.csproj net10.0
src/Ledgerline/Ledgerline.csproj                        net10.0;net8.0;netstandard2.0
tests/Ledgerline.Tests/Ledgerline.Tests.csproj          net10.0

Points worth noticing:

  • instance.Version is the SDK version (10.0.402) for a .NET SDK instance, not the MSBuild version (18.9.15).

  • Evaluation is real evaluation. TargetFrameworks for Ledgerline.csproj came through Directory.Build.props and the SDK imports exactly as in a build, and the template project (which Ledgerline.slnx does not list) was evaluated too, because the tool walks the file system rather than the solution.

  • ProjectCollection caches. A project loaded twice with the same global properties is returned from the cache; pass global properties (new ProjectCollection(new Dictionary<string,string> { ["Configuration"] = "Release" })) to evaluate a different configuration.

  • No targets ran. Evaluation computes properties and items only (How a .NET Build Works).

Building with BuildManager

BuildManager runs targets with loggers and global properties, like the command line. The restore must run as its own request, as -restore does: a restore generates the nuget.g.props/nuget.g.targets imports that the build’s evaluation needs, and a fresh MSBuildRestoreSessionId global property makes it a separate evaluation.

static class Builder
{
    public static int Run(string projectPath)
    {
        var parameters = new Microsoft.Build.Execution.BuildParameters
        {
            Loggers = [new Microsoft.Build.Logging.ConsoleLogger(Microsoft.Build.Framework.LoggerVerbosity.Minimal)],
        };
        var manager = Microsoft.Build.Execution.BuildManager.DefaultBuildManager;
        var globals = new Dictionary<string, string?> { ["Configuration"] = "Release" };

        // Restore in its own request (what -restore does), so Build evaluates the restored imports.
        var restoreGlobals = new Dictionary<string, string?>(globals) { ["MSBuildRestoreSessionId"] = Guid.NewGuid().ToString() };
        var restore = manager.Build(parameters, new Microsoft.Build.Execution.BuildRequestData(
            projectPath, restoreGlobals, null, ["Restore"], null,
            Microsoft.Build.Execution.BuildRequestDataFlags.ClearCachesAfterBuild));
        if (restore.OverallResult != Microsoft.Build.Execution.BuildResultCode.Success) return 1;

        var result = manager.Build(parameters, new Microsoft.Build.Execution.BuildRequestData(
            projectPath, globals, null, ["Build"], null));
        Console.WriteLine($"OverallResult: {result.OverallResult}");
        return result.OverallResult == Microsoft.Build.Execution.BuildResultCode.Success ? 0 : 1;
    }
}
dotnet run --project LedgerInspect -- build ~/ledgerline/src/Ledgerline.Cli/Ledgerline.Cli.csproj
MSBuild 10.0.402 from ~/.dotnet/sdk/10.0.402
  Determining projects to restore...
  All projects are up-to-date for restore.
  Ledgerline -> ~/ledgerline/src/Ledgerline/bin/Release/net10.0/Ledgerline.dll
  Ledgerline.Cli -> ~/ledgerline/src/Ledgerline.Cli/bin/Release/net10.0/Ledgerline.Cli.dll
OverallResult: Success

BuildResult.ResultsByTarget holds each target’s outputs (for example the item returned by GetTargetPath), which is how tools get data out of a build without parsing logs.

Source: MSBuild API.

Two ways to get it wrong

Shipping MSBuild in the output. Without ExcludeAssets="runtime", Microsoft.Build.Locator 1.11.2 stops the build:

~/.nuget/packages/microsoft.build.locator/1.11.2/buildTransitive/Microsoft.Build.Locator.targets(19,5): error MSBL001: A PackageReference to the package 'Microsoft.Build' at version '18.9.6' is present in this project without ExcludeAssets="runtime" and PrivateAssets="all" set. This can cause errors at run-time due to MSBuild assembly-loading.

Touching an MSBuild type before registering. The JIT loads the assemblies a method needs when it compiles that method. Adding using var early = new Microsoft.Build.Evaluation.ProjectCollection(); to the top-level statements, before RegisterDefaults(), makes Main itself need Microsoft.Build, which is not in the output folder:

Unhandled exception. System.IO.FileNotFoundException: Could not load file or assembly 'Microsoft.Build, Version=15.1.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a'. The system cannot find the file specified.

File name: 'Microsoft.Build, Version=15.1.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a'
   at Program.<Main>$(String[] args)

The assembly version is 15.1.0.0 because every MSBuild from 15 to 18 keeps that assembly version. Keep all MSBuild usage in methods (or classes) that the registering method only calls.

Choosing an instance

RegisterDefaults() picks one instance: on .NET, the SDK that dotnet --version would choose for the current folder (so global.json applies); on .NET Framework, the newest Visual Studio. To choose explicitly, enumerate and register:

var instance = MSBuildLocator.QueryVisualStudioInstances()
    .OrderByDescending(i => i.Version)
    .First();
MSBuildLocator.RegisterInstance(instance);

The .NET build of the Locator finds only .NET SDK instances, and the .NET Framework build only Visual Studio and Build Tools instances, so a tool that must load msbuild.exe (for example, to evaluate classic .NET Framework projects) targets .NET Framework.

Common mistakes

  • A Microsoft.Build package newer than the installed MSBuild — MissingMethodException at run time; compile against the oldest MSBuild you support.

  • Microsoft.Build.dll in the output folder — MSBL001, or, if forced, a build that cannot find SDK resolvers.

  • MSBuild types in the method that calls RegisterDefaults() — FileNotFoundException for 15.1.0.0.

  • Restore and Build in one request — the build evaluates without the restored imports.

  • Expecting instance.Version to be the MSBuild version — it is the SDK version for .NET SDK instances.