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, sampling profiles, and the debuggee's own console. Several Visual Studio windows can be driven from one agent session.
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.
A profile is something an agent can read rather than a file to open. profile_start and
profile_stop drive Visual Studio's own sampling collector against a process the
debugger is already holding; the trace is read and thrown away, and profile_report
answers from what is left — who calls a hot function, which of its source lines the
samples landed on, which binary the time went to, or what moved since the last
capture.
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.
.\build.ps1Requires 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.
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.
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.
51 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 frame eval vars expand watch_set memory registers disasm modules symbols scratch scratch_free |
| profiling | profile_start profile_stop profile_report |
| evidence | triage capture |
| debuggee I/O | console_read console_send output |
| build | build build_cancel build_output config startup_project |
Notes on a few:
wait—instance: "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. Where the debuggee has not run since it last stopped it says so at once instead of sitting out the timeout, because a stop cannot arrive from a program that is not running and reading that timeout as "this line is never reached" is a wrong answer the tool used to hand over.frame— the whole picture of where you are, in one call: the source around the line you stopped on, which binary that code came from and whether the file on disk still matches it, the arguments and locals,thisexpanded one level, and last the values that may be wrong with the reason for each. It takes the second reading itself first: a local the scope listing would not read is asked for again by name, and a container claiming to be empty is read with the visualizer off, which is how a vector that is not constructed yet stops reading as one that is empty. Only what will not settle is reported as doubtful, which on most frames is nothing. Each of those is readable one call at a time already; what this adds is that they are read at the same stop, and that the module and the source are read at all, which nobody does until a value has already misled them. Call it on landing somewhere unfamiliar rather than in a loop.eval— refuses to call functions unlessallowSideEffectsis passed, because the native evaluator really runs them and an agent inspectingv.size()should not change the program by accident. Format specifiers go informat, not spliced into the expression, and a type that lives in another module goes intypeModulefor the same reason:((T*)0xADDR)->MemberwithtypeModule: "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— withdataExpression, a data breakpoint: break when the memory at an address changes. WithlogMessage, 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 withcollect: truegets 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— says which binary each module actually is: the time stamped into the loaded image, its load path, size and load address, and the symbol file it found. It also marks a module whose source has been edited since it was built, and one the debuggee loaded an older build of than the one sitting beside its matched PDB. Those are the two breakpoints that bind nowhere for a reason neither the module list nor the PDB messages will show you, and the second is the one a rebuild does not fix: an engine that loads its plugins from a deployment directory keeps running what is there while the debugger matches the PDB you just built. The image's own time is the one that answers whether a binary deployed to another machine is the one you just built; the file time beside the path belongs to whatever sits at that path here.symbols— why one module's symbols are missing, and, withload, an attempt to fetch them. The report is Symbol Load Information: every path the engine tried and what each one turned out to be, which is where a PDB that is present and does not match the binary finally says so. A load does not survive the module unloading and loading again, because the Include/Exclude symbol setting is applied afresh each time; the reply says so.memory,eval,vars— a value that is nothing but an allocator's fill pattern is named where it appears, so0xddddddddddddddddreads as freed heap without anyone having to remember the table.scratch— the expression evaluator will not invent a temporary, so a function with a reference out-parameter has no argument that can be written for it and calling one was simply refused.scratchtakes a block of the debuggee's own heap and hands back the address together with the cast to paste intoeval;scratch_freegives it back, and every reply lists what is still outstanding. Taking a block runs the program's own allocator, and a block is dropped when the session that owns the heap ends.profile_start/profile_stop— Visual Studio's sampling collector, attached to a process the debugger already holds, so a profile is taken during a session rather than instead of one.profile_stopreports where the samples landed, the path most of them went down, and what each thread was doing. Function names come from the symbols the debugger has already loaded rather than from a symbol server.profile_report— asks something else of a profile already taken, without collecting again: one function's callers and callees and the source lines inside it, the same samples as a call tree, a roll-up per binary, one thread on its own, or what moved since an earlier capture in percentage points. Each is a separate reading and one is answered at a time; asking for two at once is refused rather than quietly answering one. Everywhere a report stops short it says that it stopped, and every profile accounts for the processor time actually used against how long the clock ran, or says it cannot — because a program waiting on a lock is invisible to a CPU profiler, and silence there reads as nothing being wrong.watch_set— pins expressions whose values then come back with everywaitand everystatus, instead of severalevalcalls 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,registersandmemoryfollow it across the process boundary. The choice lasts until the program next runs, because a frame does not survive its thread resuming.
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 a release
docs/marketplace.md what the listing says and what to change when the product does
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.
535 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, whether a module was deployed older than the build beside its PDB, which loaded name a mistyped module meant, 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
waitwith its id, step, run-to, set-next, exit with its code - an unhandled access violation reaching
waitasstopped: exception 0xC0000005 … unhandled, andtriageanswering it in one call, registers included - a data breakpoint catching a buffer overwrite, stopping in
memsetwith the offending line one frame up, andselectthen showing0xdeadbeefhad become0xdeadbe41 evalrefusingUpload(mesh, 1)by default and running it withallowSideEffects, withmesh.refCountgoing 1 → 2- natvis summaries (
{name="terrain" vertices={ size=4 } refCount=1 }), format specifiers,expandon astd::vectorshowing its elements console_readreturning the debuggee's own stdout,outputshowing 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:
threadslisting 8 threads across both with each group named,stackon a thread in the process that did not stop,selectby 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.
docs/iteration_2.md records the next round the same way, and is
worth reading for what hand-testing caught that the test suite did not: a tracepoint
reporting hits 0 while its records piled up, a symbol load reported as declining when
it had searched, a read after pause landing in ntdll, and — chasing that last one —
the discovery that the mode every check consulted came from a notification arriving
seconds late, so a read straight after go was being answered from the frame where the
program had last stopped.
Profiling was built the same way: the collector was proven to attach to a process Visual Studio is already debugging before any of it was written, and every reading was driven against a program whose call shape was known.
docs/iteration_3.md is the third round, from a session debugging a media engine and an Unreal application through two windows at once. Every one of its items is the same fault — a tool answering a question it could not answer, where the answer read as evidence. Driving it by hand caught two things the whole suite had passed: the Immediate-window setting was being read by a name automation does not use, so the check never fired; and a restart from a shim that had joined a live session labelled the old process's exit with the new run's number, reporting two runs as one.
Known gaps:
exceptions_setdoes not work.DTE.Debugger.ExceptionGroupsreturns 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
.slna.slnffilters 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_listshows less thanbp_setreturned — the address it watches is not readable back from the automation model. typeModuleneeds an address, not a local.((T*)0xADDR)->Mresolves; 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.
Corruptin an anonymous namespace does not bind asCorrupt; the reply says it did not bind and where to look. - CMake and Open Folder workspaces are not supported for build or launch.
attachworks regardless, so the inspection surface is available there. captureneeds 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.
- A PDB's GUID and age are not reported. AD7 does not expose them.
modulesanswers the same question by a different route — the time stamped into the loaded image, and the verbose search text fromsymbols, which is where a PDB that is present and does not match says so. - Profiling is CPU sampling only. Time spent blocked is invisible to it, which every profile says. Allocations and file I/O have collectors of their own that are not wired up.
evalrefusing a nested call has never been reproduced on Visual Studio 2026, so the advice written for that refusal has never fired. It costs nothing when it does not match, because an unrecognised message is passed through untouched.
MIT — see LICENSE.