Skip to content

Repository files navigation

vsdbgmcp

Drives the Visual Studio debugger from an AI agent, over the Model Context Protocol.

C++ is a first-class target: data breakpoints, disassembly, crash dumps, symbol diagnostics, and the debuggee's own console. Several Visual Studio windows can be driven from one agent session.

What it does

wait blocks on the debugger's own stopping events and reports why execution stopped — which breakpoint, which exception, a step completing, the process exiting. build and launch block to completion for the same reason, so nothing has to poll for a state change.

The agent launches the shim in its working directory; the shim finds the Visual Studio that has the matching solution open and connects. No ports and no per-project configuration. When the match is ambiguous the error names the candidates and the exact value to pass.

For C++: a breakpoint that cannot bind says so and why, triage collects a crash in one call, bp_set can watch an address for writes, and console_read reads the debuggee's own stdout.

Install

Install the extension and restart Visual Studio. It carries the shim and copies it to %LOCALAPPDATA%\vsdbgmcp\bin on startup; there is nothing else to download and no .NET runtime to install.

Then register that path with the agent once, globally:

claude mcp add -s user vsdbg -- "$env:LOCALAPPDATA\vsdbgmcp\bin\vsdbgmcp.exe"

Restart the agent afterwards. Every repository and every Visual Studio window works from that one entry. The panel shows the same command with the path already resolved, and copies it to the clipboard.

The shim gets a path of its own rather than staying inside the extension because Visual Studio regenerates an extension's folder on every update, and the path in the agent's configuration has to outlive that.

Building it

.\build.ps1

Requires Visual Studio 2022 or 2026 with the Visual Studio extension development workload, and the .NET 10 SDK. build.ps1 -Install copies the shim straight to where the extension stages it, for working on the shim without reinstalling the extension.

How it fits together

MCP client (Claude Code, Cursor, ...)   one global config, spawned per workspace
      | stdio
vsdbgmcp.exe  (shim, self-contained)    MCP protocol, tool surface, discovery,
      |                                 routing, aggregation
      | JSON-RPC over a named pipe, one per connected Visual Studio
VS extension (VSIX, inside devenv)      IDebugHost + IProjectSystem
                                        over DTE and IVsDebugger

The client spawns the shim and the shim finds Visual Studio, not the other way round. That is what removes the port from the configuration, and it bounds the lifetimes: the shim dies with its client, the extension with devenv.

MCP lives in the shim rather than inside devenv.exe, so the code loaded into Visual Studio is COM interop and a pipe. No dependency of ours competes with Visual Studio's own assembly versions, and the tool surface can change without reinstalling anything.

Each running instance publishes %LOCALAPPDATA%\vsdbgmcp\inst-<pid>.json with its pipe name, a token, and the workspace it has open. There is no daemon; the directory is the registry, and dead entries are pruned when anyone looks.

Full reasoning is in docs/design.md.

The panel

Extensions → Debugger MCP Server opens a docked panel (also under View → Other Windows). It appears on its own the first time an agent attaches, without taking focus, and shows:

Listening, 1 client attached
DebugTarget#100424  ·  break
vsdbgmcp-100424

Agent setup:  copy command  ·  copy path

[Pause]  [x] Don't steal focus  [Clear]

> 16:02:59  stop
> 16:02:54  bp_list      2 ms
> 16:02:49  threads      5 ms
v 16:02:31  eval         5 ms   mesh.refCount
      mesh.refCount = 1  (int)
> 16:02:18  launch      84 ms
> 16:02:12  status       3 ms
  16:02:12  client connected

Each call folds open to show the text the agent was given back, not a re-rendering, and carries the argument worth reading beside its name: which expression, which file and line, which process. The reply is selectable.

  • Pause stops every tool. They then refuse with an explanation saying a person stopped them, until Resume.
  • Don't steal focus puts the window you were using back in front when an agent starts, resumes or steps the program, instead of letting Visual Studio come forward. Stops you cause yourself are not affected: the guard arms only on an agent command that resumes execution, fires once, and disarms when the program next stops. Without that distinction it would pull focus away every time you pressed F10.
  • The list holds the last 200 calls, newest first, with how long each took; failures are marked in red. Rows left unfolded stay unfolded as new calls arrive.

There is also a vsdbgmcp pane in the Output window carrying the pipe name, client connections, and anything that went wrong inside the extension.

Tools

44 of them.

session instances use
lifecycle status launch attach detach stop restart processes dump_open
execution wait go pause step run_to set_next
breakpoints bp_set bp_list bp_remove bp_enable trace_read exceptions_set
inspection threads stack select freeze eval vars expand watch_set memory registers disasm modules
evidence triage capture
debuggee I/O console_read console_send output
build build build_cancel build_output config startup_project

Notes on a few:

  • waitinstance: "any" returns as soon as any connected window stops, which is how to debug a client and a server at once. for: "module:NAME" waits for a module to load instead of for a stop, which is how to arm breakpoints in a plugin before its host loads it without polling.
  • eval — refuses to call functions unless allowSideEffects is passed, because the native evaluator really runs them and an agent inspecting v.size() should not change the program by accident. Format specifiers go in format, not spliced into the expression, and a type that lives in another module goes in typeModule for the same reason: ((T*)0xADDR)->Member with typeModule: "Foo.dll" resolves without anyone having to know how the debugger wants that written.
  • vars — in an optimized build, marks a variable the compiler kept nothing for as not readable, and marks variables reading the same address with each other's names. Without that, a slot the optimizer handed to two locals reads as an ordinary value of both.
  • bp_set — with dataExpression, a data breakpoint: break when the memory at an address changes. With logMessage, a tracepoint: each {expr} in the message is evaluated once when the breakpoint is set, so an expression that will never work says so before it has logged a thousand records saying it.
  • trace_read — a tracepoint set with collect: true gets a stream of its own, numbered and in order, with a rate over the time it has been collecting. That is what makes a 50 Hz callback readable, and what answers "how often does this run" without inferring it from how records interleave in the Debug pane. Visual Studio writes those records to the Debug pane itself, so where the pane cannot be watched as it fills they are recovered from it afterwards and carry no individual times; the reply says so.
  • modules — also reports when each binary was built, and marks a module whose source has been edited since. That is the breakpoint that binds nowhere for a reason neither the module list nor the PDB messages will show you.
  • memory, eval, vars — a value that is nothing but an allocator's fill pattern is named where it appears, so 0xdddddddddddddddd reads as freed heap without anyone having to remember the table.
  • watch_set — pins expressions whose values then come back with every wait and every status, instead of several eval calls at each stop.
  • triage — after a crash: exception record, faulting stack, registers, memory at the fault address, and which modules were missing symbols. One call.
  • threads — every thread's top frames, grouped. It spans every process in the session, named and split, which is how to find the thread ids of a launcher when the editor it started is the one that stopped.
  • select — switch to another thread or another process, by pid or part of its name. stack, eval, vars, registers and memory follow it across the process boundary. The choice lasts until the program next runs, because a frame does not survive its thread resuming.

Layout

src/VsDbgMcp.Core    contracts and routing, no Visual Studio references
src/VsDbgMcp.Shim    the .NET 10 executable the agent launches
src/VsDbgMcp.Host    the extension; compiles Core's sources in rather than referencing
tests/               routing, discovery, events, and the shim end to end
marketplace/         listing text and publish manifest
docs/design.md       why it is shaped this way
docs/releasing.md    how to cut and publish a release

build.ps1 drives two toolchains because the halves need different ones: the shim and tests build with the dotnet CLI, and the extension needs the MSBuild inside Visual Studio, since the VSIX packaging tasks are .NET Framework assemblies.

Status

215 automated tests cover routing, discovery, the event bus, and the whole shim path — discovery file, named pipe, JSON-RPC, rendering — against a stand-in for the extension, plus the pure decisions: which expression forms to try against a module, which values are allocator fill, whether a source file outran its binary, and what a tracepoint buffer keeps.

The following were driven by hand against Visual Studio 2026 debugging a native C++ program (tests/fixtures/cpp):

  • launch, breakpoint hit reported by wait with its id, step, run-to, set-next, exit with its code
  • an unhandled access violation reaching wait as stopped: exception 0xC0000005 … unhandled, and triage answering it in one call, registers included
  • a data breakpoint catching a buffer overwrite, stopping in memset with the offending line one frame up, and select then showing 0xdeadbeef had become 0xdeadbe41
  • eval refusing Upload(mesh, 1) by default and running it with allowSideEffects, with mesh.refCount going 1 → 2
  • natvis summaries ({name="terrain" vertices={ size=4 } refCount=1 }), format specifiers, expand on a std::vector showing its elements
  • console_read returning the debuggee's own stdout, output showing the Debug pane's PDB messages, registers, memory, disassembly with source interleaved, thread grouping, freeze/thaw, build with structured errors, and routing by working directory
  • two processes in one session: threads listing 8 threads across both with each group named, stack on a thread in the process that did not stop, select by pid switching evaluation into it, and an unknown id answering with every thread that does exist and which process it is in

The eight changes in docs/iteration_1.md were driven by hand the same way, against the same fixture. One thing there has still not been seen happen: two optimized locals sharing a slot, because no frame in the fixture produced one.

Known gaps:

  • exceptions_set does not work. DTE.Debugger.ExceptionGroups returns nothing on Visual Studio 2026, so there is no category to configure. The tool reports that rather than pretending. Making it work means going to the debug engine directly, the same way expression evaluation already does.
  • Solution filters cannot be named. Visual Studio reports the .sln a .slnf filters and this SDK exposes no property for the filter itself, so two windows holding the same solution under different filters are told apart by process id. Routing still refuses to guess between them.
  • A data breakpoint listed by bp_list shows less than bp_set returned — the address it watches is not readable back from the automation model.
  • typeModule needs an address, not a local. ((T*)0xADDR)->M resolves; naming a local instead of the address does not, because the qualifier sends every name in the expression to that module and the local is not in it. Read the local first, then pass the address it holds.
  • A function breakpoint must match how the symbol is actually named. Corrupt in an anonymous namespace does not bind as Corrupt; the reply says it did not bind and where to look.
  • CMake and Open Folder workspaces are not supported for build or launch. attach works regardless, so the inspection surface is available there.
  • capture needs a window; a console program has none, and it says so.
  • Only clients in the same Windows session can use this, because the client has to spawn the shim. WSL, dev containers and remote agents cannot. See the HTTP transport entry in docs/design.md.

Licence

MIT — see LICENSE.

About

Visual Studio debugger over MCP. Event-driven waits, C++ first-class, multi-window and multi-process.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages