dotnet-native-cli 0.6.0

dotnet tool install --global dotnet-native-cli --version 0.6.0
                    
This package contains a .NET tool you can call from the shell/command line.
dotnet new tool-manifest
                    
if you are setting up this repo
dotnet tool install --local dotnet-native-cli --version 0.6.0
                    
This package contains a .NET tool you can call from the shell/command line.
#tool dotnet:?package=dotnet-native-cli&version=0.6.0
                    
nuke :add-package dotnet-native-cli --version 0.6.0
                    

dotnet-native-mcp

Status: V1 in progress. Thirteen MCP tools are live: load_native_binary, import_native_manifest, list_native_symbols, list_native_imports, resolve_symbols, extract_strings, get_size_breakdown, explain_retention, compare_native_binaries, disassemble, find_native_callers, get_r2r_header, list_r2r_runtime_functions. Standalone CLI verbs are also live: version, r2r, disasm, resolve, callers, symbols, imports, size, size-diff, strings, retention.

dotnet-native-mcp is tooling for navigating native .NET binaries — NativeAOT, R2R-only, single-file native — when ECMA-335 metadata is stripped or absent. You can use it either as an MCP server (dotnet-native-mcp) or as a standalone local CLI (dotnet-native-cli). It is designed as the third leg of a tooling triad with dotnet-assembly-mcp (managed metadata) and dotnet-diagnostics-mcp (live process events).

Why this exists

dotnet-diagnostics-mcp already attaches to NativeAOT processes and emits hotspot frames whose symbols are mangled ILC names (S_P_____...) and whose addresses point at native code. dotnet-assembly-mcp cannot answer queries on those frames — its load_assembly rejects NativeAOT binaries with module_load_failed: not a managed PE. Today, the LLM receives a hex address and a mangled symbol and has nowhere to take them.

This server closes the gap. It accepts the NativeFrame handoff (binary + symbol + address), demangles ILC symbols back to managed-looking names, disassembles the native code with Iced, and will read the sidecar artifacts ILC emits (.mstat, .map, DGML) when they are available.

Where it does not belong

  • Managed metadata, IL, decompile-to-C#. That's dotnet-assembly-mcp.
  • Live process attach, EventPipe / ETW collection. That's dotnet-diagnostics-mcp.
  • Generic reverse engineering (full Ghidra-class decompilation, full dynamic instrumentation, kernel-mode debuggers). Out of scope by design.

Tool surface (13 tools, V1)

Tool Purpose
load_native_binary Open a PE/ELF/Mach-O, verify it's a managed-flavored native build, return a handle. Accepts path + optional buildId.
import_native_manifest Bulk handshake from a producer (typically dotnet-diagnostics-mcp): register a list of native binaries in one call. Accepts entries: [{path, name?, buildId?}] + mode (lazy|eager). Per-entry failures reported inline without failing the batch.
list_native_symbols Paginated symbol table. Source priority: .map sidecar → ELF .symtab/.dynsym → PE export table. Includes raw + demangled names.
list_native_imports Paginated import/dependency walk. kind="functions" lists ELF undefined .dynsym imports or PE Import Directory entries; kind="libraries" lists ELF DT_NEEDED or PE imported DLL names.
resolve_symbols Batch address ↔ symbol lookup with ILC demangling. Accepts up to 200 hex (0x-prefixed or bare) or decimal address strings against a single image. An optional loadBase rebases absolute runtime VAs (rva = address - loadBase) so ASLR'd position-independent (PIE) NativeAOT frames resolve. Per-address failures are reported inline without failing the whole batch. Replaces the former single-address resolve_symbol and multi-frame symbolicate_stack tools.
extract_strings Paginated printable ASCII / UTF-16LE scan over .rodata / .rdata / .data.rel.ro / __const (with .data fallback). Returns section + offset for forensics.
get_size_breakdown Read the .mstat sidecar emitted by NativeAOT and aggregate native bytes by assembly, namespace, type, method, or category. Counts method bodies (code + GC + EH info), EETypes/types, and the Blobs catch-all (dehydrated data, runtime metadata, frozen-object regions, RVA static fields, manifest resources) for complete, non-overlapping accounting, and reports the mstat format version, a per-category summary, and the deduplicated-method count.
explain_retention Read the DGML reachability sidecar emitted by NativeAOT and return root → target retention paths — with the ILC retention reason on each edge (e.g. call, Reflectable type, Virtual method), each edge classified into a kind (Reflection, Generics, VirtualDispatch, DirectCode, Structural). Every path gets a verdict — reflection-driven (potentially trimmable) when any edge is reflection/metadata-driven, otherwise structural. With includeSizeCost (default on) each node is priced with the native bytes the .mstat sidecar attributes to it and each path reports the total bytes it keeps alive. maxPaths>1 returns the shortest chain from each distinct root (the independent reasons a type/method stays in the binary); ambiguous queries surface under Candidates.
compare_native_binaries Diff two loaded images: build-id, format, arch, file/section size deltas, added/removed/size-changed symbols. When both images have a sibling .mstat sidecar, also emits a managed "what grew between two builds?" size diff (build-level total delta plus top grown/shrunk buckets) at a chosen grouping (assembly/namespace/type/method/category).
disassemble Iced x86/x64 and AsmArm64 ARM64 disassembly with CALL/JMP/BL cross-ref hints. Default 64 instructions, capped at 2048. resolveSource=false (default) skips per-instruction DWARF lookup; set true to annotate each instruction with file:line. Two modes: registered-handle mode (imageHandle + address/symbolName) for images loaded via load_native_binary; or raw-bytes mode (imagePath + rva + size) for any PE/ELF/Mach-O — including managed PEs with R2R bodies — without a prior load_native_binary call.
find_native_callers Lazily-built xref index: scan all executable sections of a loaded x86-64 or ARM64 image and return every CALL/JMP/BL instruction that targets a given symbol name or hex address. The index is cached in-process (L1) and persisted to disk under ~/.cache/dotnet-native-mcp/<build-id>.xref (L2) so large NativeAOT binaries pay the scan cost only once across sessions. crossImage=true resolves call sites that branch into PLT/import/stub thunks of other loaded images (ELF PLT entries account for the architecture-specific PLT0 header — 16 bytes on x86-64, 32 on AArch64). resolveSource=true (default) annotates each call site with file:line; set false to skip PDB I/O for large binaries.
get_r2r_header Read the ReadyToRun header of a managed PE: version, decoded READYTORUN_FLAG_* names, full sections table, and the CompilerIdentifier (type 100) / OwnerCompositeExecutable (type 116) strings. includeImportSections=true decodes the ImportSections (type 101) fixup-region metadata; includeCompositeInfo=true decodes the composite-image ComponentAssemblies (type 115) entries and ManifestAssemblyMvids (type 118) GUIDs; includeMethodEntryPoints=true decodes the MethodDefEntryPoints (type 103) NativeFormat array into a MethodDef RID → RUNTIME_FUNCTION index mapping (with a has-fixups flag), capped by methodEntryPointsLimit; includeAvailableTypes=true decodes the AvailableTypes (type 108) NativeFormat hashtable into TypeDef (0x02) / ExportedType (0x27) metadata tokens for handoff to dotnet-assembly-mcp, capped by availableTypesLimit; includeInfoMaps=true decodes the V9 RID-indexed info maps — EnclosingTypeMap (type 122, nested→enclosing type tokens), MethodIsGenericMap (type 121, generic-method tokens) and TypeGenericInfoMap (type 123, per-type generic arity/variance/constraints) — capped by infoMapsLimit; includeManifestMetadata=true surfaces the ManifestMetadata (type 112) embedded ECMA-335 blob as a handoff descriptor (file offset, RVA, size, version string and stream directory) without decoding the managed metadata itself; includeHotColdMap=true decodes the HotColdMap (type 120) section into (cold, hot) RUNTIME_FUNCTION index pairs (capped by infoMapsLimit). Returns r2r_not_present for pure-managed or NativeAOT binaries.
list_r2r_runtime_functions Paginated RUNTIME_FUNCTION entries from the RuntimeFunctions section (type 102), or — with rva — a binary-search lookup of the single covering entry. x64 + ARM64; other arches return r2r_arch_unsupported.

For crash logs or sampled stacks where dotnet-diagnostics-mcp is not in the loop, use load_native_binary once and then call resolve_symbols with a list of raw hex addresses. When you already have NativeFrame handoffs with mangled addresses, pass those address strings directly to resolve_symbols.

ReadyToRun coverage

get_r2r_header + list_r2r_runtime_functions cover every R2R section whose layout is structurally tractable — decodable from raw PE bytes alone — plus the MethodDefEntryPoints array, which a safe span-based port of the runtime's NativeFormat reader now decodes:

Section (type) Surface
Header, version, decoded flags get_r2r_header
Sections table get_r2r_header
RuntimeFunctions (102), x64 + ARM64 list_r2r_runtime_functions (list + RVA lookup)
ImportSections (101) get_r2r_header includeImportSections=true
CompilerIdentifier (100) get_r2r_header (eager)
OwnerCompositeExecutable (116) get_r2r_header (eager)
ComponentAssemblies (115) get_r2r_header includeCompositeInfo=true
ManifestAssemblyMvids (118) get_r2r_header includeCompositeInfo=true
MethodDefEntryPoints (103) get_r2r_header includeMethodEntryPoints=true
AvailableTypes (108) get_r2r_header includeAvailableTypes=true (metadata tokens)
MethodIsGenericMap (121) get_r2r_header includeInfoMaps=true (generic-method tokens)
EnclosingTypeMap (122) get_r2r_header includeInfoMaps=true (nested→enclosing type tokens)
TypeGenericInfoMap (123) get_r2r_header includeInfoMaps=true (per-type generic info)
ManifestMetadata (112) get_r2r_header includeManifestMetadata=true (ECMA blob handoff descriptor)
HotColdMap (120) get_r2r_header includeHotColdMap=true ((cold, hot) RUNTIME_FUNCTION index pairs)

Out of scope — sections encoded with the runtime's NativeFormat native hashtables / nibble-compressed signature streams, or that embed an ECMA metadata blob we do not parse into managed metadata. Decoding them requires the NativeFormat hashtable reader plus Internal.TypeSystem type resolution — partly redundant with dotnet-assembly-mcp (which owns managed metadata):

InstanceMethodEntryPoints, InliningInfo*, ProfileDataInfo, PgoInstrumentationData, AttributePresence, CrossModuleInlineInfo, the remaining *Map sections, DelayLoadMethodCallThunks, ExceptionInfo, and DebugInfo. The ManifestMetadata (112) blob is located (offset/size/version/streams) for handoff but its managed metadata is decoded by dotnet-assembly-mcp.

Sidecar tier (V1+)

ILC emits structured sidecars on request:

Artifact Switch What it gives us
.mstat IlcGenerateMstatFile=true per-type / per-method native size
.map IlcMapFileType=Normal symbol → address map
DGML IlcGenerateDgmlFile=true reachability graph from the trimmer

.mstat parsing answers "what blew up my AOT binary"; DGML reachability answers "why was this type or method kept?".

CLI

The standalone CLI consumes DotnetNativeMcp.Core directly, without any MCP package dependency. It mirrors the server's native-binary capabilities for local shell workflows and ships in two release forms:

  • NuGet global tool: dotnet tool install -g dotnet-native-cli
  • Self-contained single-file archives: dotnet-native-cli-<version>-<rid>.tar.gz / .zip

The root command exposes help plus a global --output json|table switch (default json) and a repeatable --allow <path> trusted-root option that feeds the shared PathAccessPolicy. See docs/cli.md for the full CLI contract, including exit codes and allow-list behavior.

# install as a global tool
dotnet tool install -g dotnet-native-cli
dotnet-native-cli version --output table

# or run directly from source while developing
dotnet run --project src/DotnetNativeMcp.Cli -- --help
Verb Purpose
version Show the CLI tool command name, informational version, and active path-policy roots.
r2r header Decode the ReadyToRun header from a managed PE such as System.Private.CoreLib.dll.
r2r runtime-functions Page through RUNTIME_FUNCTION entries from the ReadyToRun RuntimeFunctions section.
disasm Disassemble a native image, inline bytes, or a raw blob with optional IL map/source annotations.
resolve Resolve one or more addresses to symbols, source locations, and signatures.
callers Find same-image and optional cross-image callers for a target address.
symbols Page through native symbols with optional name filtering.
imports Page through imported functions or imported libraries.
size Read a paired .mstat sidecar and aggregate size by assembly/namespace/type/method/category.
size-diff Diff two .mstat sidecars and optionally fail when growth crosses a threshold.
strings Extract printable ASCII and UTF-16LE strings, with pagination and section filtering.
retention Explain why a target symbol/type is retained using the paired DGML sidecar.
# NativeAOT fixture used by the native-image examples below
sample_aot=tests/DotnetNativeMcp.Core.Tests/bin/Release/net10.0/fixtures/SampleAot/SampleAot
sample_aot_dir=tests/DotnetNativeMcp.Core.Tests/bin/Release/net10.0/fixtures/SampleAot

# R2R managed PE published alongside SampleAot
sample_r2r=tests/fixtures/SampleAot/bin/Release/net10.0/linux-x64/System.Private.CoreLib.dll
sample_r2r_dir=tests/fixtures/SampleAot/bin/Release/net10.0/linux-x64

dotnet-native-cli version --output table
dotnet-native-cli r2r header "$sample_r2r" --allow "$sample_r2r_dir" --output table
dotnet-native-cli r2r runtime-functions "$sample_r2r" --allow "$sample_r2r_dir" --limit 5
dotnet-native-cli disasm "$sample_aot" --allow "$sample_aot_dir" --address 0x401000 --max-instructions 16 --output table
dotnet-native-cli resolve "$sample_aot" --allow "$sample_aot_dir" --address 0x401000 --address 0x401010
dotnet-native-cli callers "$sample_aot" --allow "$sample_aot_dir" --address 0x401000 --image "$sample_aot"
dotnet-native-cli symbols "$sample_aot" --allow "$sample_aot_dir" --limit 10 --output table
dotnet-native-cli imports "$sample_aot" --allow "$sample_aot_dir" --kind libraries --output table
dotnet-native-cli size "$sample_aot" --allow "$sample_aot_dir" --group-by type --top-n 10
dotnet-native-cli size-diff "$sample_aot" "$sample_aot" --allow "$sample_aot_dir" --mstat-group-by category --top-n 5
dotnet-native-cli strings "$sample_aot" --allow "$sample_aot_dir" --min-length 8 --limit 20
dotnet-native-cli retention "$sample_aot" --allow "$sample_aot_dir" --target SampleAot.Program --output table

Install

MCP server

# stdio (local MCP client)
dotnet tool install -g dotnet-native-mcp
dotnet-native-mcp --stdio

# HTTP (sidecar / multi-client)
docker run --rm -p 8789:8080 \
  -v /path/to/binaries:/binaries:ro \
  ghcr.io/pedrosakuma/dotnet-native-mcp:latest

Standalone CLI release archives

Each GitHub release also includes self-contained single-file archives for the CLI and the server:

  • dotnet-native-cli-<version>-<rid>.tar.gz / .zip
  • dotnet-native-mcp-<version>-<rid>.tar.gz / .zip

Default port: 8789. Slot picked to continue the convention started by dotnet-diagnostics-mcp (8787) and dotnet-assembly-mcp (8788).

Running the triad with Docker Compose

To bring up all three servers together (diagnostics on 8787, assembly on 8788, native on 8789):

export BINARIES_DIR=/path/to/your/nativeaot/binaries
export ASSEMBLIES_DIR=/path/to/your/managed/assemblies
docker compose -f deploy/docker-compose.yml up -d

See docs/compose.md for the full guide: env vars, bearer-token setup, MCP client configuration, and how to attach to live processes.

Verifying releases

Every release artifact (NuGet package and self-contained binary archive) is published with a SLSA build provenance attestation generated by actions/attest-build-provenance and signed by Sigstore via GitHub's OIDC issuer. The attestation proves the artifact was built by this repository on a specific commit by GitHub-hosted runners — no separate cert to install, no key to rotate.

Verify with the GitHub CLI. The --signer-workflow and --source-ref flags bind verification to the exact release workflow file and tag, so an attestation produced by a different workflow in the same repository (e.g. a hypothetical CI workflow added in a PR) cannot pass these checks:

# Server NuGet package
gh attestation verify dotnet-native-mcp.0.5.4.nupkg \
  --repo pedrosakuma/dotnet-native-mcp \
  --signer-workflow pedrosakuma/dotnet-native-mcp/.github/workflows/release.yml \
  --source-ref refs/tags/v0.5.4

# CLI NuGet package
gh attestation verify dotnet-native-cli.0.5.4.nupkg \
  --repo pedrosakuma/dotnet-native-mcp \
  --signer-workflow pedrosakuma/dotnet-native-mcp/.github/workflows/release.yml \
  --source-ref refs/tags/v0.5.4

# CLI single-file archive
gh attestation verify dotnet-native-cli-0.5.4-linux-x64.tar.gz \
  --repo pedrosakuma/dotnet-native-mcp \
  --signer-workflow pedrosakuma/dotnet-native-mcp/.github/workflows/release.yml \
  --source-ref refs/tags/v0.5.4

# Server single-file archive
gh attestation verify dotnet-native-mcp-0.5.4-linux-x64.tar.gz \
  --repo pedrosakuma/dotnet-native-mcp \
  --signer-workflow pedrosakuma/dotnet-native-mcp/.github/workflows/release.yml \
  --source-ref refs/tags/v0.5.4

For maximum strictness, also pin to the exact release commit with --source-digest <commit-sha> (look it up on the release page).

A passing verification confirms the build came from pedrosakuma/dotnet-native-mcp, on the expected tag, produced by .github/workflows/release.yml.

Disk cache

find_native_callers maintains a persistent on-disk xref index so large NativeAOT binaries are scanned only once across server restarts.

Detail Value
Location ~/.cache/dotnet-native-mcp/<build-id>.xref (Linux: $XDG_CACHE_HOME/dotnet-native-mcp/ if set)
Cache key ELF build-id / PE CodeView GUID+Age / Mach-O LC_UUID, falling back to a SHA-256 prefix of the file bytes
Format 4-byte magic NXR1 + 4-byte version + JSON body
Invalidation Automatic on binary rebuild (new build-id → new file). On format upgrade the old file is silently ignored and rebuilt.
Eviction None (files accumulate by build-id). Run rm -rf ~/.cache/dotnet-native-mcp/ to clear manually.
Disable Set DOTNET_NATIVE_MCP_XREF_CACHE=0 to bypass all disk I/O (useful in CI or read-only environments).

Authentication

HTTP transport supports optional bearer-token auth. Leave it unset for local/dev back-compat; set either NATIVE_MCP_BEARER_TOKEN or NativeMcp:BearerToken to require Authorization: Bearer <token> on every /mcp request. /health remains open. STDIO transport stays unauthenticated.

export NATIVE_MCP_BEARER_TOKEN="replace-me"
dotnet-native-mcp

Loopback-or-token invariant

The HTTP transport enforces a startup-time safety check: if any configured URL binds to a non-loopback address (anything other than 127.0.0.1, ::1, or localhost) and no bearer token is configured, the server refuses to start. This prevents accidentally exposing the MCP tool surface — which can open arbitrary files on the host — to anyone on the network.

To bind to a non-loopback address you must either:

  1. Set a bearer token (NATIVE_MCP_BEARER_TOKEN / NativeMcp:BearerToken / MCP_BEARER_TOKEN), or
  2. Explicitly opt out with NativeMcp:AllowUnauthenticatedNonLoopback=true — only safe when a trusted reverse proxy in front of the server enforces authentication.

The check inspects Urls, ASPNETCORE_URLS, HTTP_PORTS / HTTPS_PORTS (and the ASPNETCORE_* aliases), and Kestrel:Endpoints:*:Url configuration sources.

Trusted-path allowlist (untrusted path hints)

Filesystem paths arrive off the wire from an LLM and are treated as untrusted display hints, per the handoff contract. Every path passed to load_native_binary, import_native_manifest, disassemble (imagePath / ilMapPath) and the sidecar overrides (mstatPath / dgmlPath) is canonicalised — .. flattened and symlinks / junctions resolved to their true target — before any file is opened.

Enforcement is opt-in. Configure at least one trusted root to restrict opens to that subtree (anything outside is refused with path_not_allowed):

# any of these turns enforcement on
export NATIVE_MCP_ALLOWED_ROOTS="/binaries:/app/assemblies"   # PATH-separator list
export BINARIES_DIR=/binaries                                  # single root (Compose tier)
# or NativeMcp:AllowedBinaryRoots in appsettings.json / config

When enforcing, the NuGet global packages cache, the .NET shared framework, and the system temp directory are always allowed in addition to your roots so handed-off binaries and staged sidecars keep resolving. Until a root is configured the server stays permissive (paths are still canonicalised) and logs a one-time startup warning.

Resource limits

To bound memory use and adversarial inputs, the server applies the following caps:

Limit Value Applies to
MaxImageBytes 512 MiB Whole-image reads (load_native_binary, raw disassembly paths)
MaxDgmlBytes 64 MiB DGML sidecar file size
MaxIlMapBytes 16 MiB .ilmap file size
MaxManifestEntries 1024 import_native_manifest entries per call
MaxStringMatches 500,000 Total matches collected by extract_strings
MaxExtractedStringChars 16,384 Characters materialized for any single extracted string value
MaxCallerSites 100,000 find_native_callers rows returned
MaxDgmlNodes 1,000,000 DGML nodes parsed from a sidecar
MaxDgmlEdges 2,000,000 DGML edges parsed from a sidecar
MaxIlMapEntries 1,048,576 .ilmap entries parsed from a sidecar
MaxPdbBytes 64 MiB PDB files read for SourceLink resolution
MaxMapFileBytes 256 MiB .map sidecar file size
MaxMapFileEntries 5,000,000 .map sidecar entries parsed
MaxMstatBytes 256 MiB .mstat sidecar file size
MaxMstatAttributions 5,000,000 .mstat attributions parsed
MaxXrefCacheBytes 256 MiB xref disk cache size accepted on read
MaxEmbeddedPdbCacheBytes 32 MiB extracted-PDB disk cache size accepted on read

Error responses

Errors returned to MCP clients are sanitised: NativeError.Detail never carries Exception.ToString() stack traces, and absolute filesystem paths supplied by the caller are reduced to their file name. Full exception detail (including the original path and stack trace) is logged server-side via the SanitisedError.Sink hook (defaulting to System.Diagnostics.Trace) so operators can still investigate parse failures.

Building blocks

  • Iced — MIT, .NET-native x86/x64 disassembler.
  • System.Reflection.PortableExecutable — for PE headers and section reads.
  • System.IO.Pipelines — for streaming reads of large native binaries.
  • A small ELF reader (Linux NativeAOT binaries are ELF).
  • (V1) Capstone P/Invoke for ARM64 disassembly.

License

MIT.

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

This package has no dependencies.

Version Downloads Last Updated
0.6.0 104 8/29/2026