Most “build a VS Code extension” tutorials stop at tree views, status bar items, or a chat participant. If you’ve already worked through building a Cursor Rules extension or a Language Server extension, you’ve covered editor automation and code intelligence, but almost none of those guides touch the one extension category that still trips up experienced developers: a custom debugger built on the Debug Adapter Protocol (DAP), paired with a Webview panel that visualizes runtime state. That combination is exactly what powers the “Run and Debug” experience for every language VS Code and Cursor support that isn’t built in, from Deno and Zig to custom embedded toolchains and game-scripting runtimes.
This tutorial walks through building a real, working extension that registers a DAP-based debugger for VS Code 1.104 and Cursor 2.0, then adds a Webview sidebar panel that renders live variable state pulled straight from the debug session. By the end you’ll have a TypeScript project you can run with F5, set breakpoints in, and extend into a production debugger for your own language or tool.
Why the Debug Adapter Protocol still matters in 2026
Microsoft open-sourced the Debug Adapter Protocol back in 2016 specifically so editors wouldn’t need a bespoke debugger integration for every language. According to the official VS Code documentation, “Visual Studio Code implements the full MCP specification, enabling you to create MCP servers that provide tools, prompts, and resources for extending the capabilities of AI agents in VS Code” – and the same decoupled-protocol philosophy that made MCP possible is the same one DAP pioneered for debugging years earlier. DAP separates the editor UI from the debugging logic: your extension doesn’t need to know how to single-step through a binary or inspect a call stack, it just needs to speak a JSON-based protocol over stdio or a socket.
That separation matters more now than it did in 2016. Cursor, Windsurf, and other VS Code-fork editors all inherited the same extension host and the same debug adapter plumbing, so a DAP-based debugger extension you write once runs unmodified across VS Code, Cursor, and most forks. Compare that to a Language Model Tool or an MCP server registration, both of which depend on editor-specific chat and agent surfaces that vary release to release. If your goal is write-once, run-everywhere tooling, DAP is still the most stable API surface in the whole extension system.
The marketplace reflects that stability. The VS Code Marketplace has grown to more than 55,000 extensions, up from roughly 40,000 in 2022, according to a 2026 market analysis from Skillademia. A meaningful slice of those are debugger extensions for languages VS Code doesn’t support out of the box, from Rust and Deno to niche embedded and scripting runtimes used in game studios and firmware shops. For a broader look at the extension and AI tooling ecosystem around VS Code and Cursor, see our AI coding tools guide.
What you’ll build
The finished project is a minimal but fully functional “Mock++” debugger extension with three moving parts:
- A Debug Adapter process (a Node.js script) that implements the DAP message loop: launch, breakpoints, stepping, stack frames, scopes, and variables.
- A DebugConfigurationProvider and DebugAdapterDescriptorFactory registered through the extension’s activation code, wiring the adapter into VS Code’s “Run and Debug” panel.
- A Webview panel that subscribes to debug session events and renders a live, styled view of the current call stack and variables, something the built-in Variables view doesn’t let you customize.
Every step below is runnable. You won’t need a real compiler or runtime to follow along: the mock adapter simulates “executing” a text file line by line, which is exactly the same pattern Microsoft uses in its own reference implementation, so the concepts transfer directly to a real debugger for your own language.
Prerequisites and exact versions
Lock these versions before you start. DAP and the extension API are both stable, but minor mismatches between the extension generator and VS Code’s typings are the single most common source of “it worked in the tutorial but not for me” bug reports.
| Tool | Minimum version | Verify with |
|---|---|---|
| Node.js | 20.x LTS or newer | node --version |
| npm | 10.x or newer | npm --version |
| Visual Studio Code | 1.104 or newer | Help → About |
| Cursor (optional, for cross-editor testing) | 2.0 or newer | Cursor → About Cursor |
| Yeoman + VS Code Extension Generator | generator-code 1.11.x |
yo --version |
| @vscode/debugadapter | 1.68.x | npm ls @vscode/debugadapter |
| TypeScript | 5.6.x or newer | tsc --version |
| @vscode/debugprotocol | 1.68.x (matches debugadapter) | npm ls @vscode/debugprotocol |
You’ll also need basic familiarity with TypeScript and the VS Code extension activation lifecycle (activate()/deactivate()). If you’ve never built a VS Code extension before, skim the official debugger extension guide first; this tutorial assumes you know what package.json contribution points are.
Step 1: Scaffold the extension project
Install the generator tooling globally, then scaffold a new extension. The generator asks a handful of questions; answer them as shown.
npm install -g yo generator-code
yo code
# ? What type of extension do you want to create? New Extension (TypeScript)
# ? What's the name of your extension? mockpp-debugger
# ? What's the identifier of your extension? mockpp-debugger
# ? Bundle the source code with webpack? No
# ? Initialize a git repository? Yes
# ? Which package manager to use? npm
cd mockpp-debugger
npm install @vscode/debugadapter @vscode/debugprotocol
npm install --save-dev @types/vscode @vscode/vsce
Open the resulting folder in VS Code. You should see the default src/extension.ts, a package.json, and a .vscode/launch.json already configured for the “Extension Development Host” – the sandboxed VS Code window the generator uses to test extensions without touching your real install.
Step 2: Declare the debugger contribution point
VS Code discovers debugger extensions through the contributes.debuggers array in package.json. This is the piece most tutorials gloss over: the schema here controls the launch.json autocomplete your users see, so getting it right up front saves a lot of rework.
{
"contributes": {
"debuggers": [
{
"type": "mockpp",
"label": "Mock++ Debug",
"languages": ["plaintext"],
"configurationAttributes": {
"launch": {
"required": ["program"],
"properties": {
"program": {
"type": "string",
"description": "Absolute path to a text file to 'debug'.",
"default": "${workspaceFolder}/program.txt"
},
"stopOnEntry": {
"type": "boolean",
"description": "Automatically stop after launch.",
"default": true
}
}
}
},
"initialConfigurations": [
{
"type": "mockpp",
"request": "launch",
"name": "Debug with Mock++",
"program": "${workspaceFolder}/program.txt",
"stopOnEntry": true
}
]
}
]
}
The configurationAttributes.launch.properties block is what drives IntelliSense when a user edits their own launch.json after installing your extension. Skipping descriptions here is the single most common reason debugger extensions get poor marketplace reviews: users can’t tell what a launch property does without reading your README.
Step 3: Choose an adapter execution mode
VS Code supports three ways to run your debug adapter, registered via a DebugAdapterDescriptorFactory: as a separate executable, as an inline implementation inside the extension host, or over a server socket. For a first build, the inline mode is the fastest to iterate on because you skip process spawning entirely and can set breakpoints in the adapter code itself from the extension host.
| Mode | Best for | Debuggable from extension host? |
|---|---|---|
Inline (DebugAdapterInlineImplementation) |
New extensions, rapid iteration | Yes |
Executable (DebugAdapterExecutable) |
Adapters written in another language (Go, Python, Rust) | No (separate process) |
Server (DebugAdapterServer) |
Adapters already running as a long-lived service | No (attach separately) |
This tutorial uses inline mode throughout. If you later port this to a real compiled language, swap to executable mode and point it at your compiled adapter binary – the DAP message contract doesn’t change.
Step 4: Implement the debug session class
Create src/mockDebugSession.ts. This class extends LoggingDebugSession from @vscode/debugadapter and implements the DAP request handlers VS Code calls during a debug session: launchRequest, setBreakPointsRequest, threadsRequest, stackTraceRequest, scopesRequest, and variablesRequest.
import {
LoggingDebugSession, InitializedEvent, StoppedEvent, TerminatedEvent,
Thread, StackFrame, Scope, Source, Handles
} from '@vscode/debugadapter';
import { DebugProtocol } from '@vscode/debugprotocol';
import * as fs from 'fs';
interface LaunchArgs extends DebugProtocol.LaunchRequestArguments {
program: string;
stopOnEntry?: boolean;
}
export class MockDebugSession extends LoggingDebugSession {
private lines: string[] = [];
private currentLine = 0;
private breakpoints = new Set();
private variableHandles = new Handles();
protected initializeRequest(
response: DebugProtocol.InitializeResponse
): void {
response.body = response.body || {};
response.body.supportsConfigurationDoneRequest = true;
response.body.supportsStepBack = false;
this.sendResponse(response);
this.sendEvent(new InitializedEvent());
}
protected launchRequest(
response: DebugProtocol.LaunchResponse,
args: LaunchArgs
): void {
this.lines = fs.readFileSync(args.program, 'utf8').split('n');
this.currentLine = 0;
this.sendResponse(response);
if (args.stopOnEntry) {
this.sendEvent(new StoppedEvent('entry', 1));
} else {
this.continueExecution();
}
}
protected setBreakPointsRequest(
response: DebugProtocol.SetBreakpointsResponse,
args: DebugProtocol.SetBreakpointsArguments
): void {
this.breakpoints.clear();
const actual = (args.breakpoints || []).map(bp => {
this.breakpoints.add(bp.line);
return { verified: true, line: bp.line };
});
response.body = { breakpoints: actual };
this.sendResponse(response);
}
protected threadsRequest(response: DebugProtocol.ThreadsResponse): void {
response.body = { threads: [new Thread(1, 'main')] };
this.sendResponse(response);
}
protected stackTraceRequest(
response: DebugProtocol.StackTraceResponse
): void {
response.body = {
stackFrames: [
new StackFrame(0, this.lines[this.currentLine] || '',
new Source('program.txt'), this.currentLine + 1)
],
totalFrames: 1
};
this.sendResponse(response);
}
protected scopesRequest(response: DebugProtocol.ScopesResponse): void {
response.body = {
scopes: [new Scope('Locals', this.variableHandles.create('locals'), false)]
};
this.sendResponse(response);
}
protected variablesRequest(
response: DebugProtocol.VariablesResponse
): void {
response.body = {
variables: [
{ name: 'currentLine', value: String(this.currentLine + 1), variablesReference: 0 },
{ name: 'rawText', value: this.lines[this.currentLine] || '', variablesReference: 0 }
]
};
this.sendResponse(response);
}
private continueExecution(): void {
while (this.currentLine = this.lines.length) {
this.sendEvent(new TerminatedEvent());
} else {
this.sendEvent(new StoppedEvent('step', 1));
}
}
}
This is a simplified version of Microsoft’s own mock-debug reference extension, trimmed to the handlers you actually need for a first working build: initialize, launch, breakpoints, threads, stack trace, scopes, variables, continue, and step. A real adapter for a compiled or interpreted language replaces the file-reading logic with calls into your language’s runtime or debug API, but the DAP message shapes stay identical.
Step 5: Wire the adapter into the extension host
Now connect the session class to VS Code’s debug subsystem inside src/extension.ts. This is where the DebugAdapterDescriptorFactory and DebugConfigurationProvider get registered.
import * as vscode from 'vscode';
import { MockDebugSession } from './mockDebugSession';
export function activate(context: vscode.ExtensionContext) {
const factory = new InlineDebugAdapterFactory();
context.subscriptions.push(
vscode.debug.registerDebugAdapterDescriptorFactory('mockpp', factory)
);
context.subscriptions.push(
vscode.debug.registerDebugConfigurationProvider('mockpp', {
resolveDebugConfiguration(folder, config) {
if (!config.type && !config.request) {
config.type = 'mockpp';
config.name = 'Debug with Mock++';
config.request = 'launch';
config.program = '${workspaceFolder}/program.txt';
config.stopOnEntry = true;
}
return config;
}
})
);
}
class InlineDebugAdapterFactory
implements vscode.DebugAdapterDescriptorFactory {
createDebugAdapterDescriptor(): vscode.ProviderResult {
return new vscode.DebugAdapterInlineImplementation(new MockDebugSession() as any);
}
}
export function deactivate() {}
Press F5 now. A new Extension Development Host window opens. Create a program.txt with a few lines of plain text in a workspace folder, open the Run and Debug panel, select “Debug with Mock++,” and set a breakpoint on any line. VS Code will stop there and show your mock variables in the built-in Variables panel – proof the DAP plumbing is working end to end before you add any custom UI.
Step 6: Add a Webview panel for custom variable visualization
The built-in Variables view is plain text. For debuggers dealing with structured state – game entity trees, GPU buffers, parsed AST nodes – a Webview panel lets you render that state however you want: tables, diagrams, colored diffs. Register the Webview in activate().
let panel: vscode.WebviewPanel | undefined;
function createVariablePanel(context: vscode.ExtensionContext) {
panel = vscode.window.createWebviewPanel(
'mockppVariables',
'Mock++ Variables',
vscode.ViewColumn.Beside,
{ enableScripts: true, retainContextWhenHidden: true }
);
panel.webview.html = getWebviewHtml();
context.subscriptions.push(
vscode.debug.onDidChangeActiveStackItem(async (item) => {
if (!item || !panel) return;
const session = vscode.debug.activeDebugSession;
if (!session) return;
const vars = await session.customRequest('variables', {
variablesReference: 1
});
panel.webview.postMessage({ type: 'update', variables: vars.variables });
})
);
}
function getWebviewHtml(): string {
return `
Name Value
window.addEventListener('message', (event) => {
const { type, variables } = event.data;
if (type !== 'update') return;
const table = document.getElementById('vars');
table.innerHTML = 'Name Value ' +
variables.map(v => `${v.name} ${v.value} `).join('');
});
`;
}
Call createVariablePanel(context) from inside a vscode.debug.onDidStartDebugSession listener so the panel only opens when a Mock++ session actually starts. According to the official VS Code documentation, “Model Context Protocol (MCP) is an open standard for connecting AI models to external tools and services” – the Webview API predates MCP by years and uses a completely separate message-passing model (postMessage over an iframe boundary), but the two are commonly combined in modern extensions: a debugger Webview today might also surface an “Explain this stack trace” button that calls into a registered Language Model Tool, the same API covered in our guide to building a VS Code AI extension with the Language Model API.
Step 7: Handle breakpoint verification properly
The mock implementation above marks every breakpoint as verified: true unconditionally, which works for a demo but will mislead users in a real debugger. If your underlying runtime can’t actually stop at a given line (inside a comment, a blank line, an inlined function), you must set verified: false and VS Code will gray out that breakpoint in the gutter instead of showing a misleading solid red dot.
const actual = (args.breakpoints || []).map(bp => {
const lineText = this.lines[bp.line - 1] || '';
const isValid = lineText.trim().length > 0 && !lineText.trim().startsWith('#');
if (isValid) this.breakpoints.add(bp.line);
return { verified: isValid, line: bp.line };
});
Step 8: Add conditional and logpoint support
Modern DAP clients send condition and logMessage fields on each breakpoint. Declaring support for these in your capabilities response unlocks the right-click “Add Conditional Breakpoint” and “Add Logpoint” menu items in VS Code’s gutter.
protected initializeRequest(response: DebugProtocol.InitializeResponse): void {
response.body = response.body || {};
response.body.supportsConfigurationDoneRequest = true;
response.body.supportsConditionalBreakpoints = true;
response.body.supportsLogPoints = true;
response.body.supportsHitConditionalBreakpoints = true;
this.sendResponse(response);
this.sendEvent(new InitializedEvent());
}
Then in setBreakPointsRequest, read bp.condition and bp.logMessage off each incoming breakpoint object and store them alongside the line number so continueExecution() can evaluate them before deciding to stop.
Step 9: Package the extension with vsce
Once the debugger and Webview both work in the Extension Development Host, package it into a distributable .vsix file.
npx @vscode/vsce package
# Output:
# mockpp-debugger-0.0.1.vsix
# Install locally to test outside the dev host:
code --install-extension mockpp-debugger-0.0.1.vsix
# Or in Cursor:
cursor --install-extension mockpp-debugger-0.0.1.vsix
Expected output from a clean package run looks like this:
$ npx @vscode/vsce package
INFO Validating extension manifest...
INFO Packaging extension...
DONE Packaged: mockpp-debugger-0.0.1.vsix (14 files, 42.3KB)
If you see a warning about a missing repository field or LICENSE file, vsce will still package the extension but the marketplace listing quality score will suffer. Add both before publishing publicly.
Step 10: Test cross-editor compatibility with Cursor
Because Cursor forks the VS Code OSS extension host, the same .vsix installs and runs without modification. Open Cursor, go to Extensions, choose “Install from VSIX,” and select the file you just packaged. Set a breakpoint in a text file the same way you did in VS Code; the debug session, call stack, and your custom Webview panel should all behave identically. The one thing to verify manually is the Webview’s Content Security Policy – Cursor’s build occasionally ships a stricter default CSP than upstream VS Code, so test the panel loads before assuming parity.
Step 11: Publish to the Visual Studio Marketplace
Publishing requires a free Azure DevOps organization and a Personal Access Token with Marketplace “Manage” scope, then a publisher ID registered once via vsce create-publisher.
npx @vscode/vsce login your-publisher-id
# paste your PAT when prompted
npx @vscode/vsce publish patch
# bumps 0.0.1 -> 0.0.2 and publishes in one step
Publishing typically takes 5-15 minutes to appear in marketplace search after the CLI reports success. Open the publisher dashboard on the Visual Studio Marketplace site to confirm the listing went live before announcing it anywhere.
Step 12: Add a status bar indicator for session state
A small but high-value polish step: show a status bar item that reflects whether a Mock++ session is active, running, or paused at a breakpoint. Users debugging unfamiliar extensions frequently lose track of session state when it’s only shown in the Debug sidebar.
const statusItem = vscode.window.createStatusBarItem(
vscode.StatusBarAlignment.Left, 100
);
vscode.debug.onDidStartDebugSession((session) => {
if (session.type !== 'mockpp') return;
statusItem.text = '$(debug-alt) Mock++: running';
statusItem.show();
});
vscode.debug.onDidTerminateDebugSession((session) => {
if (session.type !== 'mockpp') return;
statusItem.hide();
});
Step 13: Add Debug Console output and logging
The Debug Console panel at the bottom of VS Code’s Run and Debug view is where print statements, log lines, and adapter diagnostics should surface, and it’s a piece most first-time adapter authors miss entirely because nothing in the base class wires it up automatically. You push text into it by sending an OutputEvent from inside your session class whenever your simulated program “prints” something.
import { OutputEvent } from '@vscode/debugadapter';
private logLine(text: string, category: 'stdout' | 'stderr' | 'console' = 'stdout') {
this.sendEvent(new OutputEvent(`${text}n`, category));
}
protected launchRequest(
response: DebugProtocol.LaunchResponse,
args: LaunchArgs
): void {
this.lines = fs.readFileSync(args.program, 'utf8').split('n');
this.currentLine = 0;
this.logLine(`Mock++ loaded ${this.lines.length} lines from ${args.program}`);
this.sendResponse(response);
if (args.stopOnEntry) {
this.sendEvent(new StoppedEvent('entry', 1));
} else {
this.continueExecution();
}
}
Route genuine adapter errors to the stderr category so VS Code renders them in red, which gives users an immediate visual signal that something in the debugging session itself failed, as opposed to normal program output. Keep adapter-internal diagnostic logging (protocol tracing, timing) behind a trace launch argument rather than always-on, since a chatty Debug Console is one of the fastest ways to make an extension feel unpolished.
Step 14: Harden the Webview with a content security policy
The minimal Webview from Step 6 injects HTML without a Content Security Policy, which works in a tutorial but is not safe to ship. Every Webview should declare a strict CSP and use a per-load nonce so inline scripts can’t be hijacked by any value that ends up rendered into the page, including variable names or values from the program being debugged.
import * as crypto from 'crypto';
function getWebviewHtml(webview: vscode.Webview): string {
const nonce = crypto.randomBytes(16).toString('base64');
const csp = [
`default-src 'none'`,
`style-src ${webview.cspSource} 'unsafe-inline'`,
`script-src 'nonce-${nonce}'`
].join('; ');
return `
Name Value
window.addEventListener('message', (event) => {
const { type, variables } = event.data;
if (type !== 'update') return;
const table = document.getElementById('vars');
const esc = (s) => String(s).replace(/&/g,'&').replace(//g,'>');
table.innerHTML = 'Name Value ' +
variables.map(v => '' + esc(v.name) + ' ' + esc(v.value) + ' ').join('');
});
`;
}
Two things changed beyond the CSP header itself. First, the script tag carries the same nonce referenced in the policy, so only that exact script block is allowed to execute. Second, variable names and values are HTML-escaped before being written into the table, closing the injection path a malicious or malformed program under debug could otherwise exploit against the panel rendering its output.
Extending the mock adapter to a real language runtime
Everything above uses a text file as a stand-in for a real program so the tutorial doesn’t depend on any specific compiler or interpreter. Moving to a real language means replacing the file-stepping logic in mockDebugSession.ts with a child process that actually runs your runtime’s debug protocol, then translating whatever that runtime emits into the DAP events shown throughout this guide.
import { spawn, ChildProcessWithoutNullStreams } from 'child_process';
export class RealDebugSession extends LoggingDebugSession {
private runtime: ChildProcessWithoutNullStreams | undefined;
protected launchRequest(
response: DebugProtocol.LaunchResponse,
args: LaunchArgs
): void {
this.runtime = spawn('my-language-runtime', ['--debug', args.program]);
this.runtime.stdout.on('data', (chunk: Buffer) => {
this.handleRuntimeMessage(chunk.toString());
});
this.runtime.stderr.on('data', (chunk: Buffer) => {
this.logLine(chunk.toString(), 'stderr');
});
this.runtime.on('exit', () => this.sendEvent(new TerminatedEvent()));
this.sendResponse(response);
}
private handleRuntimeMessage(raw: string) {
// Parse whatever line-stop, breakpoint-hit, or variable-dump
// format your runtime emits here, then call this.sendEvent(...)
// with the matching DAP event (StoppedEvent, OutputEvent, etc).
}
}
The handleRuntimeMessage method is where almost all of your real debugger’s complexity lives, and it’s entirely specific to whatever protocol your runtime already speaks, whether that’s GDB/MI, a JSON line-based trace format, or a bespoke binary protocol over a socket. The DAP-facing half of the adapter, everything covered in Steps 1 through 14, does not need to change: VS Code still only ever sees standard DAP requests and events, regardless of what’s happening inside your runtime bridge.
Step 15: Write adapter-level tests
DAP adapters are easy to unit test because the message contract is just JSON in, JSON out. Install @vscode/debugadapter-testsupport and drive the session with a mock client instead of a real editor.
npm install --save-dev @vscode/debugadapter-testsupport mocha
import { DebugClient } from '@vscode/debugadapter-testsupport';
describe('Mock++ Debug Adapter', () => {
let dc: DebugClient;
beforeEach(async () => {
dc = new DebugClient('node', './out/mockDebugSession.js', 'mockpp');
await dc.start();
});
afterEach(() => dc.stop());
it('stops on entry', async () => {
await Promise.all([
dc.configurationSequence(),
dc.launch({ program: 'test/fixtures/program.txt', stopOnEntry: true }),
dc.assertStoppedLocation('entry', { line: 1 })
]);
});
});
Run this suite in CI on every push. Debugger regressions are notoriously hard to catch by hand-testing because they usually only show up on specific stepping sequences, not on a fresh launch. If you want VS Code’s own Testing UI to surface these results instead of a terminal log, pair this suite with the Test Explorer integration from our VS Code Test Explorer extension tutorial.
Common pitfalls when building DAP-based extensions
These are the mistakes that show up most often in extension bug trackers and Stack Overflow threads for custom debuggers.
- Forgetting
sendEvent(new InitializedEvent()). Without it, VS Code never sends yoursetBreakPointsRequestorconfigurationDoneRequest, and the session appears to hang on launch with no error. - Sending responses out of order. Each DAP request must get exactly one response with a matching
request_seq. Firing an event before the matching response can desync the client-side state machine. - Blocking the Node.js event loop in
launchRequest. A synchronous, long-running compile or build step inside the launch handler freezes the entire extension host, not just your adapter, because inline adapters run in-process. - Not declaring the right
languagesarray. If your debugger’scontributes.debuggers.languagesentry doesn’t match the file’s language mode, “Run and Debug” won’t offer your debugger as an option for that file type. - Treating the Webview as trusted content. Any variable value rendered into the Webview’s HTML without escaping is an injection risk if that value ever comes from user-controlled program state; always sanitize before interpolating into the DOM.
- Skipping
retainContextWhenHidden. Without it, switching tabs away from and back to your Webview panel destroys and recreates its state, losing scroll position and any client-side caching. - Mismatched
@vscode/debugadapterand@vscode/debugprotocolversions. These two packages are versioned together; installing them separately without pinning matching majors causes cryptic TypeScript errors about incompatible response shapes.
Troubleshooting guide
Work through these in order if your debugger extension isn’t behaving as expected.
| Symptom | Likely cause | Fix |
|---|---|---|
| “Run and Debug” doesn’t list your debugger | contributes.debuggers missing or extension not activated |
Check activationEvents includes onDebug or onDebugResolve:mockpp |
| Session hangs with no breakpoints hit | Missing InitializedEvent |
Send it after initializeRequest completes, before configuration-done |
| Breakpoints show as unverified (gray) | Line validation logic rejecting valid lines | Log the raw args.breakpoints payload and compare against your validation rule |
| Webview panel is blank | Content Security Policy blocking inline script | Add a nonce to your <script> tag and matching CSP meta tag |
| Extension works in VS Code but not Cursor | Stricter default Webview CSP in Cursor’s build | Explicitly set localResourceRoots and a permissive-but-scoped CSP |
| “Cannot find module ‘@vscode/debugadapter’” at runtime | Dependency bundled incorrectly or not included in .vsixmanifest |
Run vsce ls before packaging to confirm it’s included in the file list |
| Step-over does nothing visible | nextRequest not sending a StoppedEvent afterward |
Every stepping request must end in either a StoppedEvent or a TerminatedEvent |
vsce publish fails with 401 |
Expired or wrong-scope Personal Access Token | Regenerate the PAT in Azure DevOps with Marketplace “Manage” scope specifically |
| Variables panel shows stale data after stepping | Not re-requesting variablesRequest on each stop |
VS Code caches by variablesReference; issue a new handle each stop |
Advanced tip: combine DAP with a Language Model Tool
Once your debugger works, a natural extension is adding a Language Model Tool that an AI chat agent can call mid-debug session to explain the current stack trace in plain English. According to VS Code’s official documentation, “Language model tools enable you to extend the functionality of a large language model (LLM) in chat with domain-specific capabilities.” You register the tool with vscode.lm.registerTool, and inside its handler, call vscode.debug.activeDebugSession.customRequest('stackTrace', ...) to pull live frames, then hand that text to the model as tool output. This pattern – DAP for ground-truth runtime state, a Language Model Tool as the explanation layer – is distinct from registering an MCP server, since the tool runs in-process and has direct access to the active debug session object, something an external MCP server cannot reach.
Advanced tip: multi-session and nested debugging
Real-world debuggers for things like multi-process servers or parent/child script runners need to handle more than one active DebugSession at once. VS Code supports this through vscode.debug.startDebugging() called recursively with a parentSession option, which nests the child session under the parent in the Call Stack view. If you’re building a debugger for anything that spawns subprocesses, plan for this from the start; retrofitting multi-session support onto a single-session adapter design usually means rewriting the breakpoint and variable-handle bookkeeping from scratch.
Advanced tip: performance under large variable sets
The naive variablesRequest implementation above returns every variable in one response. For a real runtime with thousands of locals, globals, or array elements, that creates a visible stall every time the debugger stops. DAP supports paging through the start and count fields on VariablesArguments – implement lazy expansion so array and object children are only resolved when the user actually expands them in the tree, not eagerly on every stop event.
Complete project structure reference
Here’s the full file layout for the finished extension, for reference when your own project starts to sprawl.
mockpp-debugger/
├── package.json # contributes.debuggers, activationEvents
├── tsconfig.json
├── src/
│ ├── extension.ts # activate()/deactivate(), factory + provider registration
│ ├── mockDebugSession.ts # DAP request handlers
│ └── webviewPanel.ts # Webview HTML + postMessage bridge
├── test/
│ ├── fixtures/
│ │ └── program.txt
│ └── adapter.test.ts
├── .vscode/
│ └── launch.json # Extension Development Host config
└── out/ # compiled JS output
DAP vs. Language Model Tools vs. MCP servers: when to use each
Developers new to the extension API often conflate these three integration points because all three can technically “add intelligence” to an editor session. They solve different problems. For a deeper walkthrough of the MCP registration path specifically, see our guide to building a Cursor MCP server extension.
| API | Purpose | Runs where | Use when |
|---|---|---|---|
| Debug Adapter Protocol | Step-through execution, breakpoints, variable inspection | Separate process, server, or inline in extension host | You’re building support for a language or runtime VS Code can’t debug natively |
| Language Model Tool | Give a chat agent a callable function with deep editor API access | In-process, extension host | You want an AI agent to call into live editor/debug state directly |
| MCP Server | Expose tools, prompts, and resources to any MCP-compatible client | External process, outside the extension host | The functionality should be reusable outside VS Code/Cursor, or hosted remotely |
Per VS Code’s own extensibility guidance, a Language Model Tool is the right choice “when functionality needs deep access to VS Code extension APIs,” while an MCP server fits when the capability “should be externally hosted or automatically invoked by agent mode.” A debugger extension almost always needs DAP as its foundation; the Language Model Tool and MCP layers are optional add-ons once the core debugging experience works.
Choosing between building your own adapter and reusing an existing one
Not every debugging need justifies a from-scratch DAP implementation. Before committing to the full build described in this tutorial, check whether your target language or runtime already has a maintained debug adapter you can wrap instead of replacing. A growing number of runtimes expose DAP support directly or through a thin community-maintained bridge, and reusing one of those is almost always less work than writing and maintaining request handlers yourself.
| Scenario | Recommended approach |
|---|---|
| Language already has a DAP-speaking debug server (e.g. a `dap` mode flag) | Write a thin extension that spawns it in server mode and skip a custom session class entirely |
| Language has no DAP support, but has a scriptable debugger (gdb, lldb, a REPL) | Build a bridge adapter like the one in this tutorial, translating its output into DAP events |
| You’re debugging a custom DSL, config format, or game-scripting language with no existing tooling | Full custom adapter as covered here; you own both sides of the protocol translation |
| You only need to visualize state, not control execution | Skip DAP, use a Webview or custom editor fed by a simpler extension API instead |
The mock adapter built across this tutorial sits in the third category: a fully custom bridge for a language VS Code knows nothing about. If your situation matches the first or second row instead, you can skip most of the state-machine logic in Steps 4 through 8 and focus your effort on the translation layer between your existing debug server’s output and the DAP events VS Code expects.
Frequently asked questions
Do I need a compiler or real runtime to follow this tutorial?
No. The Mock++ adapter built in this guide “debugs” a plain text file by stepping through its lines, which is the same approach Microsoft uses in its own reference mock-debug extension. Once the DAP plumbing works, you swap the file-reading logic for real calls into your language’s runtime, debug API, or remote debugging socket.
Will a debugger extension built this way work in Cursor without changes?
Yes, in almost all cases. Cursor is built on the same VS Code OSS extension host and ships the same vscode.debug namespace, so a packaged .vsix installs and runs identically. The one area worth testing manually is Webview Content Security Policy behavior, since Cursor’s default CSP has occasionally been stricter than upstream VS Code in recent builds.
What’s the difference between inline, executable, and server debug adapter modes?
Inline mode runs your adapter code directly inside the extension host process, which is fastest to develop and debug but ties your adapter’s lifecycle to the extension’s. Executable mode spawns a separate process VS Code manages for you, useful when your adapter is written in a language other than JavaScript or TypeScript. Server mode connects to an adapter already listening on a socket, useful for adapters that run as a long-lived background service shared across multiple editor windows.
Can a single extension register both a debugger and an MCP server?
Yes. There’s nothing preventing one extension’s activate() function from calling both vscode.debug.registerDebugAdapterDescriptorFactory and vscode.lm.registerMcpServerDefinitionProvider. They’re independent contribution points and commonly combined when a debugger also wants to expose its runtime state to external AI agents through MCP’s standardized interface.
Why does my Webview panel lose its state when I switch tabs?
By default, VS Code disposes a Webview’s DOM when it’s hidden to save memory, then recreates it from scratch when shown again. Pass retainContextWhenHidden: true in the WebviewOptions when calling createWebviewPanel to keep the underlying iframe and its JavaScript state alive in the background, at the cost of higher memory usage for that panel.
How long does it take for a published extension to appear in marketplace search?
Typically 5 to 15 minutes after vsce publish reports success, though indexing for search relevance can take longer to stabilize. The extension is usually installable by direct link or code --install-extension publisher.name immediately, even before it’s fully searchable.
Does this approach work for debugging in notebooks or remote/SSH workspaces?
Debug adapters registered this way work in VS Code Remote-SSH, WSL, and container workspaces without extra configuration, since the extension host and your inline adapter run on the remote side alongside the user’s code. Notebook debugging uses a related but separate API surface layered on top of DAP, which is out of scope for this tutorial but builds on the same request/response handlers shown here.
Does publishing to the Visual Studio Marketplace cost anything?
No. Creating a publisher ID and uploading extensions through vsce is free, and it uses the free tier of Azure DevOps purely as an identity and token-issuing layer; you don’t need an active Azure subscription or any paid service to publish or update an extension.
What license should I use for an open-source debugger extension?
MIT is the most common choice across the VS Code extension ecosystem, including Microsoft’s own sample extensions such as the mock-debug reference implementation this tutorial’s structure is based on. Whatever license you pick, include a LICENSE file in the project root before running vsce package, since its absence is flagged on the marketplace listing page and can discourage adoption even when the extension itself works correctly.