Skip to main content
Glama
nevse
by nevse
README.md
# DotnetDebugger.Mcp

An MCP server that lets an AI assistant debug .NET programs: launch or attach to a process, set
breakpoints, step through code, inspect variables and evaluate expressions. It also carries a
searchable reference on how .NET debuggers work internally.

Published on NuGet as [`DotnetDebugger.Mcp`](https://www.nuget.org/packages/DotnetDebugger.Mcp).
Built on [clrdbg](https://github.com/JaneySprings/clrdbg), driven over the Debug Adapter Protocol. The
debugger travels inside the package and runs as a child process, so a crash in native debugging code
costs the debug session rather than the server.

> **Origin.** A fork of [decriptor/SharpDbg.MCP](https://github.com/decriptor/SharpDbg.MCP), now far
> enough from it to carry its own name: the debugger layer speaks the Debug Adapter Protocol instead of
> calling a debugger's internal API, the debugger underneath has since changed from
> [SharpDbg](https://github.com/MattParkerDev/sharpdbg) to clrdbg, launching a program under the
> debugger is new, and the two have not shared a commit since. This repository was called
> `SharpDbg.MCP` until the first release, and GitHub redirects the old name. The `SHARPDBG_*` settings
> were renamed to `DOTNET_DEBUGGER_MCP_*` in the release that followed 0.6.3; the old names are no
> longer read.

## What it can do

- Attach to a running .NET process, or launch one and stop before its first line executes
- Set breakpoints by file and line or by method name, with conditions and hit counts
- Step over, into and out of code, and read the call stack for any thread
- Inspect local variables, expand objects member by member, and evaluate C# expressions
- Break on exceptions - all of them, only the unhandled ones, or only the types you name - and read
  what was thrown
- Read the debuggee's stdout and stderr
- Debug more than one process at once, each with its own breakpoints and stops
- Debug a .NET MAUI app on an Android emulator or phone, an iOS simulator or device, or this Mac as
  a Mac Catalyst app - building it and stopping before its first line, same as a desktop program
- Search embedded documentation on ICorDebug, the Debug Adapter Protocol and expression evaluation

## What it cannot do

Some of these need changes in the underlying debugger rather than here.

- **Report the exit code of a process it attached to.** `exit_code` is `null` there. The debugger
  reads the code off a process it started itself and has none for one it was pointed at, so for an
  attached process there is nothing to report — and the protocol cannot say "unknown", only `0`,
  which would be a number this server made up. A program started with `launch_program` does report
  its real code.
- **Debug a self-contained single-file publish.** The runtime is packed inside the executable, so the
  debugger shim cannot find it to load the matching components. The attempt fails immediately with
  `CORDBG_E_DEBUG_COMPONENT_MISSING` (`0x80131C3C`), reported as
  `Attempting to register for runtime startup failed: -2146231236`. Self-contained on its own works,
  and single-file on its own works; only the combination does not.
- **Watch expressions**, **hot reload** and **data breakpoints** are not implemented.

## Requirements

- .NET 10 SDK or later
- An MCP-compatible client, such as Claude Code or Claude Desktop
- Windows, macOS or Linux

## Install

One package carries the debugger and its native shims for every platform, so the same configuration
works everywhere. There is nothing to clone or build.

**Claude Code**, for the current project:

```bash
claude mcp add dotnet-debugger -- dotnet tool exec DotnetDebugger.Mcp --version 0.8.0 --yes
```

Add `--scope user` to make it available in every project, or `--scope project` to write a `.mcp.json`
that is committed and shared with your team.

**VS Code**, in `.vscode/mcp.json` for the workspace, or with **MCP: Open User Configuration** for every
workspace. VS Code calls the section `servers`, not `mcpServers`:

```json
{
  "servers": {
    "dotnet-debugger": {
      "type": "stdio",
      "command": "dotnet",
      "args": ["tool", "exec", "DotnetDebugger.Mcp", "--version", "0.8.0", "--yes"]
    }
  }
}
```

**Claude Desktop**, by editing `~/.config/Claude/claude_desktop_config.json` on macOS and Linux, or
`%APPDATA%\Claude\claude_desktop_config.json` on Windows:

```json
{
  "mcpServers": {
    "dotnet-debugger": {
      "command": "dotnet",
      "args": ["tool", "exec", "DotnetDebugger.Mcp", "--version", "0.8.0", "--yes"]
    }
  }
}
```

`dnx DotnetDebugger.Mcp --version 0.8.0 --yes` is the shorter equivalent, and the form NuGet.org
suggests. Prefer `dotnet tool exec` in a client launched from a desktop environment rather than from a
shell: `dnx` lives in the SDK directory, which such a client often does not have on its `PATH`, while
`dotnet` reliably is.

A client only connects its MCP servers when a session starts, so restart it after changing the
configuration. To confirm the server is there, run `claude mcp list` or ask the client to list .NET
processes.

### Why the version is pinned

Every command here names a version. Without `--version`, `dotnet tool exec` resolves the package on
each start, so a newly published release would run on the next session without anyone having chosen
it. A debugger can read and change everything in the process it attaches to, so which build of it runs
should be a decision rather than a side effect. Change the version in the command to upgrade; the
latest is on [nuget.org](https://www.nuget.org/packages/DotnetDebugger.Mcp).

Installing the tool once instead avoids the resolution step on every start:

```bash
dotnet tool install -g DotnetDebugger.Mcp --version 0.8.0
```

The command is then `dotnet-debugger-mcp`, with no arguments. This needs `~/.dotnet/tools` on your
`PATH`, which is why it is not the default suggestion: a client that cannot find the command fails
the same way a wrong path does.

### Checking where a package came from

Releases after 0.6.1 carry a build provenance attestation: a statement, signed through GitHub, that
this repository's release workflow built that package from a given commit. To check one before
mirroring or installing it, download the `.nupkg` from nuget.org and run:

```bash
gh attestation verify DotnetDebugger.Mcp.<version>.nupkg --repo nevse/dotnet-debugger-mcp
```

nuget.org adds its own repository signature to every package it accepts, which changes the file, so
the release workflow attests both: the package as built, attached to the GitHub Release, and nuget.org's
copy, once it has checked that copy holds exactly what was built. For 0.6.2 only the first exists:
verify the copy from `gh release download v0.6.2 -R nevse/dotnet-debugger-mcp -p '*.nupkg'`.

### Upgrading

`dotnet tool exec` can keep running a version it has already downloaded, even after a newer one is
published and indexed. Nothing looks wrong when it does: the server starts, the client reports it as
connected, and the only sign is that the new release's tools are missing.

Measured while releasing 0.1.1. With only 0.1.0 in the local package folder, the unpinned command kept
running 0.1.0, and clearing the NuGet HTTP cache did not change that. Naming the version once fetched
the new package, after which the unpinned command used it too:

```bash
dotnet tool exec DotnetDebugger.Mcp --version 0.1.1 --yes
```

With the version pinned, upgrading is changing it in the command, and the question does not arise.
An installed tool is upgraded explicitly too:

```bash
dotnet tool update -g DotnetDebugger.Mcp --version <new version>
```

To see which version is actually running, ask the client to list this server's tools, or read the
first line the server writes to stderr — it names the version at startup. A client's own health check
reports only that the process started, not what it is.

## A worked example

Catching a program before it has run a single line, which is the case attaching cannot reach:

```
User: "My app throws before it prints anything. Find out why."

Claude: [launch_program("/path/to/bin/Debug/net10.0/MyApp.dll")]
"Prepared, not running yet. Setting a breakpoint on the first line of Main."

Claude: [set_breakpoint("/path/to/Program.cs", 12)]
"Breakpoint set. It is unverified for now — nothing can bind before the program has
loaded its modules — and takes effect when the program starts."

Claude: [start_program()]
Claude: [wait_for_stop()]
"Stopped at Program.cs:12, before a single line has run."

Claude: [step_over(thread_id: 1)]
Claude: [get_variables(frame_id: 0)]
"configPath is null, and the next line passes it to File.ReadAllText."

Claude: [get_program_output()]
"The program printed nothing before the throw, which matches what you saw."
```

## Tools

Each tool declares MCP annotations, so a client that reads them can approve the read-only ones without
asking and still ask for the rest. The last column says which:

- **read-only** - reads the session or the machine and changes nothing.
- **session** - changes only the debug session: stepping, resuming, removing a breakpoint. Not
  destructive.
- **runs getters** - reads that run the debuggee's own code: the debugger calls `ToString`, property
  getters and `DebuggerDisplay` to show a value. Not marked read-only, though not destructive either.
- **ends a launched program** - destructive: a program the session launched is killed.
- **runs code** - destructive and open-world: it starts, builds or evaluates code the agent chose. A
  breakpoint's condition is a C# expression evaluated at every hit, so setting one counts.

The annotations are hints, and a client is free to ignore them.

### Debugging

| Tool | What it does | Annotation |
|---|---|---|
| `list_dotnet_processes` | List the .NET processes running on this machine | read-only |
| `attach_to_process` | Attach the debugger to a running .NET process | runs code |
| `launch_program` | Prepare a program to run under the debugger, stopped before it starts | runs code |
| `start_program` | Run the program prepared by `launch_program` | runs code |
| `get_process_status` | Report whether the session is running, stopped, and where | read-only |
| `wait_for_stop` | Block until the debuggee stops, instead of polling | read-only |
| `get_program_output` | Read what the debuggee wrote to stdout and stderr | read-only |
| `detach_from_process` | Detach, leaving the process running | ends a launched program |
| `list_sessions` | List open sessions and what each is debugging | read-only |
| `close_session` | Close a session, detaching first if needed | ends a launched program |

### .NET MAUI

Needs a one-off setup - see [Debugging a .NET MAUI app](#debugging-a-net-maui-app).

| Tool | What it does | Annotation |
|---|---|---|
| `list_mobile_devices` | List Android emulators and phones, iOS simulators and devices, and this Mac | read-only |
| `build_mobile_app` | Build a MAUI project for one of them, against CoreCLR and with the debugging library inside | runs code |
| `launch_mobile_app` | Prepare the built app for debugging, stopped before it starts; `start_program` runs it | runs code |

### Breakpoints

| Tool | What it does | Annotation |
|---|---|---|
| `set_breakpoint` | Set or update a breakpoint at a file and line, with an optional condition or hit count | runs code |
| `set_function_breakpoint` | Set a breakpoint on a method by name, when the file and line are not known | runs code |
| `remove_breakpoint` | Remove a breakpoint of either kind | session |
| `list_breakpoints` | List this session's breakpoints and whether each is verified | read-only |
| `set_exception_break_mode` | Choose which exceptions stop the program: all, unhandled, or named types | session |

### Execution and inspection

| Tool | What it does | Annotation |
|---|---|---|
| `continue_execution` | Resume until the next breakpoint or exit | session |
| `pause_execution` | Break into the debugger where the program currently is | session |
| `step_over`, `step_into`, `step_out` | Step by line, into a call, or out of the current method | session |
| `get_threads` | List the threads in the debuggee | read-only |
| `get_stack_trace` | Read the call stack for a thread | read-only |
| `get_variables` | Read the locals of a stack frame | runs getters |
| `expand_variable` | Expand an object into its members, which may expand further | runs getters |
| `evaluate_expression` | Evaluate a C# expression in the context of a frame | runs code |
| `get_exception_info` | Read the type, message, stack trace and inner exception of what was thrown | runs getters |

### Documentation

| Tool | What it does | Annotation |
|---|---|---|
| `search_debugging_concepts` | Search the embedded documentation | read-only |
| `explain_icordebug_interface` | Explain a specific ICorDebug interface | read-only |
| `get_debugging_flow` | Walk through a debugging operation step by step | read-only |
| `list_debugging_concepts` | Browse the concept catalogue by category | read-only |

## Things worth knowing

### Breakpoints need portable PDBs

A breakpoint binds through the target's symbols, so the debuggee must be built with portable PDBs
sitting next to its assembly. A missing or mismatched PDB is the most common reason `set_breakpoint`
answers `verified: false` with `No symbols have been loaded for this document`.

Debug builds already do this. For a Release build, or any project that changes the defaults:

```xml
<PropertyGroup>
  <DebugType>portable</DebugType>
  <DebugSymbols>true</DebugSymbols>
</PropertyGroup>
```

Optimized code also moves locals out of reach, so `get_variables` is only fully useful with
`<Optimize>false</Optimize>`.

### macOS: headless environments may need an entitlement

macOS will not let a debugger take another process's task port unless the target carries
`com.apple.security.get-task-allow`. A program run through the `dotnet` muxer is fine, because the
muxer ships with that entitlement; an apphost produced by `dotnet publish` is ad-hoc signed with no
entitlements at all.

The debugger's own side of this needs nothing from you: the debug adapter is started through the
`dotnet` muxer as well, so it inherits the entitlements that let it debug at all.

**On a desktop session this does not affect you.** Debugging a self-contained publish carrying no
entitlements is verified to work there.

It matters in a headless environment, such as a CI runner, and it fails badly when it does: macOS
refuses by blocking rather than returning an error, so the debugger stops responding and the call
never comes back. If a launch or attach hangs on macOS with no error at all, sign the target and try
again:

```bash
codesign -s - -f --entitlements get-task-allow.entitlements ./MyApp
```

with `get-task-allow.entitlements` containing:

```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>com.apple.security.get-task-allow</key>
  <true/>
</dict>
</plist>
```

### What Just My Code changes about stepping

`DOTNET_DEBUGGER_MCP_JUST_MY_CODE=false` does not make a step stop in code you have no symbols for. Neither
setting does: a step that lands in a module without symbols steps straight back out, because there is
no source to report a location against. A step through an interpolated string goes through
`System.Private.CoreLib` and comes back to the next statement of your own method either way.

What the setting changes is which modules get their symbols looked for at all. With Just My Code on,
only assemblies built by you are searched; with it off, every module is, so a step does surface inside
a dependency you happen to have symbols for. That is what to turn it off for.

### What `launch_program` will run

The debugger starts whatever it is given, so `launch_program` only accepts a .NET app: a `.dll` with
its `.runtimeconfig.json` beside it, or an apphost with the app's `.dll` and `.runtimeconfig.json`
beside it under the same name. That covers a normal build and a self-contained publish. A shell, a
system tool or any other native executable is refused, as is a single-file publish, which cannot be
debugged anyway.

Environment variables that load other code into the program are refused too, since any of them turns
launching a legitimate app into running something else: `DOTNET_STARTUP_HOOKS`,
`DOTNET_ADDITIONAL_DEPS`, `DOTNET_SHARED_STORE`, `DOTNET_ROOT*`, the CoreCLR profiler variables
(`CORECLR_ENABLE_PROFILING`, `CORECLR_PROFILER*`, `COR_ENABLE_PROFILING`, `COR_PROFILER*`), ASP.NET
Core's hosting startup assemblies (`ASPNETCORE_HOSTINGSTARTUPASSEMBLIES`,
`DOTNET_HOSTINGSTARTUPASSEMBLIES`), `LD_PRELOAD`, `LD_LIBRARY_PATH`, `LD_AUDIT` and every `DYLD_*`.

A `launchSettings.json` profile is read by this server, not handed to the debugger. Pass `project_path`
(the project file or its directory) and optionally `launch_profile`. With no name given, the `https`
profile is used, or else the first profile with `commandName: "Project"`. Its `commandLineArgs`,
`workingDirectory` and `environmentVariables` become the launch's arguments, working directory and
environment, and `applicationUrl` becomes `ASPNETCORE_URLS`. These are then checked the same way as the
parameters above. Arguments and a working directory passed to `launch_program` replace the profile's.
Passed environment variables are added on top of the profile's. Only `Project` profiles are accepted,
because an `Executable` profile names a program of its own, and `executablePath` is ignored, so the
program is always the one passed in. Values are used literally: neither `$(...)` nor `%VAR%` is expanded.
The profile comes with the repository, so it gets no more trust than the agent does: a startup hook set
in it is refused like any other, and the error names the profile. A project with no `launchSettings.json`
is launched without a profile unless one was named.

This is not a sandbox, and it does not stop `launch_program` from serving as a shell. A .NET app can do
anything its user can, an agent able to write and build code can launch what it built, and ready-made
.NET apps that run other programs are already on the machine: the SDK's own
`sdk/<version>/dotnet.dll` has its `.runtimeconfig.json` beside it, so launching it reaches the whole
`dotnet` CLI, building and running any project included. `evaluate_expression` can call
`Process.Start` in whatever is being debugged. What the check stops is the agent naming a native
program directly. Treat a client that allows this server's tools as one that allows a shell, and
confirm `launch_program`, `evaluate_expression`, `build_mobile_app` and `attach_to_process` calls rather
than approving them automatically, along with `set_breakpoint` and `set_function_breakpoint`, whose
condition is code run at every hit. All of these are annotated as destructive and open-world, together
with `start_program` and `launch_mobile_app` (see [Tools](#tools)), so a client that honours the
annotations can be set to ask for those and approve the reads.

### Secrets in what the debugger shows

Values read from the debuggee go to the model, so `get_variables`, `expand_variable`,
`evaluate_expression`, `get_exception_info` and `get_program_output` mask what looks like a secret
first, as `[redacted: N chars]`. Two rules decide it:

- **By name.** A string whose variable or member name contains `password`, `passwd`, `pwd`, `secret`, `token`,
  `apiKey`, `connectionString`, `credential`, `privateKey`, `accessKey` or `authorization` is masked
  whole. Only strings: an `int tokenCount` or a `CancellationToken token` shows as it is. An object is
  not masked, and its members are checked when they are expanded. For `evaluate_expression` the name is
  the last member the expression reads, so `config.Db.Password` counts.
- **By format.** In any text, named or not: `Bearer` and `Basic` credentials, JWTs, `Password=`,
  `Pwd=`, `ApiKey=`, `sig=` and the like inside connection strings and URLs, keys with a well-known
  prefix (`sk-`, `ghp_`, `github_pat_`, `AKIA`, `xoxb-`, `AIza`), and PEM private keys. Only the secret
  itself is masked, so the rest of a connection string stays readable.

`null` and an empty string are shown as they are, since whether a secret is set at all is often the
question. Each response says how many values it masked in `redacted`.

The server's own log, which MCP clients usually keep on disk, records only how long each line of program
output was. The text itself is logged at `DOTNET_DEBUGGER_MCP_LOG_LEVEL=Debug` and below, masked one line at a
time, so a secret printed over several lines, such as a PEM key, may be masked only in part there. Errors the
debugger reports after a request gave up waiting, and the debugger's own diagnostic output, are masked too.

This reduces exposure; it does not prevent it. A secret in a variable called `s`, in a format the
patterns do not know, or read piecemeal - `evaluate_expression` of `password.Length` or `password[0]` -
passes. Set `DOTNET_DEBUGGER_MCP_REDACT_SECRETS=false` to see values unmasked; the agent has no way to
turn it off itself.

### Running code in the program

`evaluate_expression` runs whatever the expression calls - a property getter, `ToString`, any method,
`Process.Start` included - with the program's privileges, and listing variables runs property getters,
`ToString` overrides and `DebuggerTypeProxy` constructors to show values. That is how a debugger shows
what a program holds, and `DOTNET_DEBUGGER_MCP_EVAL_TIMEOUT_MS` is the only bound on it by default.

Set `DOTNET_DEBUGGER_MCP_ALLOW_FUNC_EVAL=false` to stop the debugger running the program's code, whether to
show a value or because an expression asks it to. Fields, locals and arguments are read as usual. A
property is listed as `[not evaluated: function evaluation is off]`, an object shows as its type name in
braces, and an expression that calls anything - a getter, a method, a constructor - fails. The agent has
no way to turn it back on. Two things still run code:

- `get_exception_info` calls the exception's `Message` and `InnerException` getters to report them.
- Reading a static field of a type that has not been initialized yet runs that type's static
  constructor, in `evaluate_expression` and when static members are expanded alike.

A breakpoint condition is an expression too, so one that calls a getter or a method - `items.Count > 3`
- cannot be evaluated with this off. Such a breakpoint stops at every hit, as if it had no condition,
and nothing in the stop says why. Compare fields instead, or leave the condition out and check after
the stop.

This narrows what debugging can run; it does not make the server safe to hand to an agent you do not
trust. `launch_program` still starts any .NET program.

### Attaching to other users' processes

A debugger can read and change everything in the process it attaches to, so by default this server
attaches only to processes belonging to the user it runs as. `attach_to_process` refuses anything
else before it looks at the process at all, and `list_dotnet_processes` marks each entry with an
`owner` of `current_user`, `other_user` or `unknown`.

`unknown` is refused as well. On Windows that is what a system or elevated process looks like, since
its token cannot be opened, and treating it as your own would make the check decorative wherever the
lookup does not work.

`DOTNET_DEBUGGER_MCP_ALLOW_OTHER_USER_PROCESSES=true` lifts the restriction. The operating system still has its
own say: on Linux and macOS a normal user cannot attach to another user's process even with this
enabled, so in practice it matters when the server runs elevated or as root.

### Debugging more than one process

By default the server debugs one process at a time, and `DOTNET_DEBUGGER_MCP_MAX_SESSIONS` raises that. Each
session has its own process, breakpoints and stops.

`attach_to_process` and `launch_program` return a `session_id`. While only one session is open you
can ignore it, because every tool defaults to the only session. Taking on a second process opens a
second session rather than failing, and from then on `session_id` becomes required: with two
processes open, guessing which one a `continue_execution` was meant for would be worse than asking.

`detach_from_process` leaves the session open and free, so the next attach or launch reuses it rather
than taking another slot.

The default of one is deliberate. Every attach carries a risk of a native crash inside the debugging
shim, so more sessions means more exposure. What such a crash costs is bounded: each session drives its
own debug adapter in a process of its own, so the one that crashes takes its session with it and leaves
the server and any other session running.

### Debugging a .NET MAUI app

A MAUI app is debugged the same way a desktop program is - prepare, set breakpoints, start - but two
things have to be true before it can be, and neither is true of an app built the ordinary way.

**It has to run on CoreCLR.** This debugger attaches to CoreCLR and nothing else, while a MAUI app
still builds against Mono by default. CoreCLR is there from `net10.0-android`, and from `net11.0` for
`-ios` and `-maccatalyst`; below those versions the app would build, start, and never connect back,
so `build_mobile_app` refuses them and says which version would work.

**It has to carry the remote debugging library.** Debugging a runtime on a phone takes a native
library inside the app, which opens the connection the debugger attaches through, and a matching one
on this side. Both are open source, part of [clrdbg](https://github.com/JaneySprings/clrdbg), and ship
in this package from the clrdbg release the debugger is built from, so there is nothing to install
or set up:

```
remote-coreclr/
  remote-host/     osx-arm64/, osx-x64/, win-x64/, win-arm64/, linux-x64/, linux-arm64/
  remote-target/   android/, ios/, maccatalyst/
```

To use a copy built yourself instead - one with tracing compiled in, say - point
`DOTNET_DEBUGGER_MCP_REMOTE_CORECLR_HOST` at a `remote-host` directory, `DOTNET_DEBUGGER_MCP_REMOTE_CORECLR_TARGET` at a
`remote-target` one, or both. Either half left unset comes from the bundled copy.

Visual Studio's own remote debugger libraries (`vsdbg*`) are not used, wherever they come from: a
directory holding a file with `vsdbg` in its name is refused, bundled or configured.

Then the session looks like this:

```
list_mobile_devices()                                → ids to choose from
build_mobile_app(project_path, device_id)            → the .apk or .app, built to be debuggable
launch_mobile_app(project_path, device_id)           → prepared, not started
set_breakpoint(file, line)                           → in place before the app's first line
start_program()                                      → boots, installs, launches: minutes, not seconds
wait_for_stop()                                      → and from here it is an ordinary debug session
```

`build_mobile_app` adds an MSBuild file of its own to the build - it ships beside the server as
`Resources/CopyRemoteCoreclrTargetLibrary.targets`, and is readable - which puts the target library
into the app bundle or the Android package. Nothing in your project changes.

Some notes on what each platform does:

- **Android.** Pick an emulator by its AVD name; it does not have to be running, because the debugger
  boots a cold one itself. A phone is picked by its adb serial. The app is built for every ABI at
  once, so there is no runtime identifier to choose. The app's logcat output arrives in
  `get_program_output`. `uninstall_app: true` clears a previous install first, which is what to reach
  for when a signature or a permission has changed.
- **iOS.** A simulator is picked by its UDID and booted if it is not running. A physical device has to
  be provisioned for the app the usual way - the debugger installs and launches it, it does not sign
  it.
- **Mac Catalyst.** The device id is `maccatalyst`; there is nothing to boot or install, which makes
  it the quickest way to check that the setup works at all.

`process_id` stays `null` throughout: the app runs on the device, so its pid is not one this machine
could be pointed at. Everything else - breakpoints, stepping, locals, expressions, exceptions -
behaves exactly as it does locally.

### How failures are reported

Every tool reports a failure the same way:

```json
{
  "success": false,
  "error": "Returned from a call to Continue that was not matched with a stopping event. (0x8013132F)",
  "explanation": "The process was already running, so there was nothing to resume. Check get_process_status before continuing."
}
```

`error` is whatever the debugger said, kept verbatim. `explanation` says what the failure means and
what to do about it, for the `CORDBG_E_*` results the debugger raises: a process that has exited, an
operation that needs the debuggee stopped, a variable that is not live at this instruction, a frame id
from an earlier stop, another debugger already attached. It is `null` for failures that are not the
debugger's, such as invalid arguments.

## Configuration

Set these as environment variables in your client's configuration:

```json
{
  "mcpServers": {
    "dotnet-debugger": {
      "command": "dotnet",
      "args": ["tool", "exec", "DotnetDebugger.Mcp", "--version", "0.8.0", "--yes"],
      "env": {
        "DOTNET_DEBUGGER_MCP_LOG_LEVEL": "Debug"
      }
    }
  }
}
```

| Variable | Description | Default |
|---|---|---|
| `DOTNET_DEBUGGER_MCP_LOG_LEVEL` | `Trace`, `Debug`, `Information`, `Warning`, `Error` or `Critical` | `Information` |
| `DOTNET_DEBUGGER_MCP_MAX_SESSIONS` | Sessions open at once, each debugging its own process | `1` |
| `DOTNET_DEBUGGER_MCP_OPERATION_TIMEOUT_SECONDS` | Bounds every request to the debugger other than an evaluation: attaching, starting, pausing, stepping, reading state and closing a session. The debugger answers one request at a time, so a request it is still busy with holds up everything after it until this runs out | `30` |
| `DOTNET_DEBUGGER_MCP_EVAL_TIMEOUT_MS` | Bounds `evaluate_expression` and `get_exception_info`. Minimum 100 | `5000` |
| `DOTNET_DEBUGGER_MCP_BREAKPOINT_BIND_TIMEOUT_MS` | How long to wait for a breakpoint to bind before reporting it unverified, minimum 100 | `2000` |
| `DOTNET_DEBUGGER_MCP_JUST_MY_CODE` | Restrict debugging to your own code. See above before turning this off | `true` |
| `DOTNET_DEBUGGER_MCP_ALLOW_FUNC_EVAL` | Let the debugger run code in the program: property getters, `ToString`, method calls in `evaluate_expression`. See above | `true` |
| `DOTNET_DEBUGGER_MCP_REDACT_SECRETS` | Mask what looks like a secret in values before the model sees them. See above | `true` |
| `DOTNET_DEBUGGER_MCP_ALLOW_OTHER_USER_PROCESSES` | Allow attaching to processes not owned by the current user | `false` |
| `DOTNET_DEBUGGER_MCP_ENABLE_DIAGNOSTICS` | Detailed diagnostic logging | `false` |
| `DOTNET_DEBUGGER_MCP_REMOTE_CORECLR_HOST` | A `remote-host` directory to use instead of the bundled one, for debugging a MAUI app. See above | bundled |
| `DOTNET_DEBUGGER_MCP_REMOTE_CORECLR_TARGET` | A `remote-target` directory to use instead of the bundled one | bundled |
| `DOTNET_DEBUGGER_MCP_MOBILE_START_TIMEOUT_SECONDS` | Bounds `start_program` for a mobile app, which boots the emulator, installs and launches | `600` |
| `DOTNET_DEBUGGER_MCP_BUILD_TIMEOUT_SECONDS` | Bounds `build_mobile_app` | `900` |

## Troubleshooting

**The server does not appear in the client.** Check that the path in the configuration is absolute
and correct, then fully quit and restart the client. Claude Desktop logs to `~/Library/Logs/Claude/`
on macOS and `%APPDATA%\Claude\logs\` on Windows.

**Attaching fails.** The usual causes are that the process is owned by another user (see above), has
already exited, is not a .NET process, or already has a debugger attached. `list_dotnet_processes`
shows what the server can see and who owns it.

**A breakpoint is not hit.** Check that it came back `verified: true`. If not, the PDB is the first
thing to look at. If it is verified and still not hit, confirm the line is actually reached and that
the file path matches the one the PDB records.

**`list_dotnet_processes` comes back empty on macOS or Linux.** Module enumeration needs permissions
there, and the server falls back to detection by process name, which misses programs whose apphost is
renamed.

**A launch or attach hangs on macOS with no error.** See the entitlement section above.

**A MAUI app starts and never stops at a breakpoint.** It is almost certainly running on Mono rather
than CoreCLR - which happens when it was built by something other than `build_mobile_app`, since an
ordinary `dotnet build` produces neither the runtime nor the library the debugger needs. Rebuild with
`build_mobile_app` and launch the app it reports.

**`list_mobile_devices` shows no Android devices.** It reads emulators from the AVD directory and
connected phones from adb, so it needs the Android SDK: set `ANDROID_HOME` or `ANDROID_SDK_ROOT` if it
is somewhere unusual. The `warnings` in the response say which source could not be read and why.

To see what the server is doing, set `DOTNET_DEBUGGER_MCP_LOG_LEVEL=Trace` and
`DOTNET_DEBUGGER_MCP_ENABLE_DIAGNOSTICS=true`, or run it directly and watch stderr:

```bash
dotnet tool exec DotnetDebugger.Mcp --version 0.8.0 --yes
```

## Development

```bash
git clone https://github.com/nevse/dotnet-debugger-mcp.git
cd dotnet-debugger-mcp
dotnet build
dotnet test
```

The build takes the debug adapter from the clrdbg release `ClrdbgVersion` names, checks its hash and
puts it next to the server, so there is no separate step. To build the adapter from a clrdbg checkout
instead - a fork carrying a fix, say - point `ClrdbgSourcePath` at it:

```bash
ClrdbgSourcePath=~/work/clrdbg dotnet build
```

The integration tests drive a real debuggee with real breakpoints, so they are slower than the rest
and are separated by `TestCategory=Integration`.

To point a client at your working copy instead of the published package:

```bash
claude mcp add dotnet-debugger -- dotnet run --project "$(pwd)/src/DotnetDebugger.Mcp/DotnetDebugger.Mcp.csproj"
```

[CONTRIBUTING.md](CONTRIBUTING.md) covers the layout and conventions.
[docs/RELEASING.md](docs/RELEASING.md) covers cutting a release, including the nuget.org
trusted-publishing policy that the workflow depends on but does not show.

## Related projects

- [clrdbg](https://github.com/JaneySprings/clrdbg) — the .NET debugger this server drives
- [SharpDbg](https://github.com/MattParkerDev/sharpdbg) — the debugger it ran on before the move
- [ClrDebug](https://github.com/lordmilko/ClrDebug) — ICorDebug API wrapper
- [Model Context Protocol](https://github.com/modelcontextprotocol) — the specification and SDKs

## License

[MIT](LICENSE)