Instruction file imported from bsantos/DocForgeMCP (
.github/instructions/vscode-extension.instructions.md). Copyright stays with the author.
VS Code extension (layer 4)
The extension is a launcher. It resolves a toolchain, computes arguments, supervises
a process, and shells out to docforge. If a change would put doc parsing, contract
extraction, or index logic into TypeScript, it belongs in the Python CLI instead.
It serves the LLM path only, and never runs docforge html. Layer 1 is an on-demand
published artifact whose consumer is a human with a browser, built by CI or by hand;
reaching for it from the editor would drag the [html] extra into every editor install,
put a long build behind a click, and turn a launcher into a documentation front-end whose
surface then grows with layer 1 forever. What the extension exists for is making the
contract index running and visible without anyone remembering to do it. Any proposed
editor-side capability that does not serve the LLM path lands in the CLI instead.
It ships the CLI, and no binary. The .vsix carries docforge as a pure-Python
py3-none-any wheel built by scripts/bundle.mjs, which is packaging rather than logic
— the Python side must stay fully usable with no extension present.
Doxygen is never bundled: it is GPL-2.0, a large per-platform payload most C++ developers
already have, and redistributing it means owning its security updates. A missing or
out-of-range one is a diagnostic naming the
exact install command for the platform. Nothing in editor/vscode/ may add a
compiled artifact to the package: text needs no signature, no notarization, and no
per-platform archive, and that is the whole reason bundling is affordable.
Provisioning the bundled CLI
- The wheel is installed onto the user's own interpreter, never vendored with its
dependencies.
mcpandwatchfilesship CPython-minor-tagged wheels, so vendoring would turn the>=3.12floor into an equality. The user's pip picks the matching ones (src/venv.ts), which is the one time any layer touches the network. - The environment lives in global storage, keyed by interpreter path, interpreter
version and extension version. Never in the workspace and never in
.docforgemcp/, which is derived statedocforge cleanmay remove. Old keys are pruned; a stale venv's failure mode is a compiled wheel loading against the wrong ABI, which reports itself as an unrelated crash deep inside a dependency. - A stamp file is written last, so a half-installed environment is never mistaken for a usable one and a retry starts from scratch rather than repairing.
- A failure keeps the exact invocation and its stderr on the status surface and is
retriable from the menu. A degraded launch is never offered: without the
mcpextra there is no server, and pretending otherwise is invariant 5 gone. - The bundle is resolved ahead of
PATH, because it is the copy the extension was tested against — that is what makes a version mismatch between the two halves impossible rather than merely reportable.docforgemcp.serverPathstill overrides it, and a project.venvis still a candidate behind it.
Module and test conventions
These are load-bearing, not style. Each one has cost a debugging session.
- Tests are
.mts, never.ts. The package must stay CommonJS —mainis a CJS bundle — so a.tstest makes Node warnMODULE_TYPELESS_PACKAGE_JSONand re-parse it as ESM. Adding"type": "module"to silence that breaks the bundle. The warning still appears for the imported.tsmodules themselves and has no fix that keeps CommonJS. - Relative imports carry an explicit
.tsextension (allowImportingTsExtensionswithnoEmit): the specifier must resolve under esbuild and under Node's type-stripping test run, and an extensionless import only satisfies the first. - A type-only import needs the inline
typemodifier, in tests and in any module a test imports. Node strips types syntactically and cannot infer that a name is a type, so a plainimport { Project }type-checks and then dies at runtime with does not provide an export named 'Project'.tscwill not catch it. exactOptionalPropertyTypesis on. A property that legitimately receivesundefinedis declared?: T | undefined— on every optional property in a shape, including inside an object-literal type, not just on the shape itself.- No
shell: true. A user-supplied path handed to a shell is an injection surface.uv toolandpipxinstall.exeshims, whichCreateProcessfinds, so nothing reachable is lost. - npm 11 does not run
postinstallby default, so esbuild's is skipped and warns. The bundle still builds — the platform binary arrives as an optional dependency. Do not "fix" this by approving scripts. - The launcher is proved by spawning what it computes, not by an extension host
(
launch.test.mts). VS Code cannot be asked to start a contributed MCP server from a test, so do not add@vscode/test-electronfor one; what is left host-side is the provider id, compared againstpackage.jsonin a unit test.
Packaging
.vscodeignoreis a deny list, so the archive is asserted as an exact set (vsix.test.mts). A file added to this directory ships unless someone remembers to exclude it, and the mistake is visible only inside an archive nobody opens. A new file is therefore either ignored or acknowledged in that test. The wheel's name is derived frompyproject.toml, so itspy3-none-anytag is asserted rather than assumed.npm run vsixneedsuvonPATH, becausenpm run packagebuilds the wheel with it. Any workflow that packages therefore installsuv, even when it runs no Python of its own.LICENSEis a copy, kept identical to the repo root's.vscepackages from the extension directory and sees nothing above it, and a symlink would be archived as a file containing its target. The manifest's SPDX id must name the same licence the file states.repositoryis required, not decorative. Without itvsce packagerefuses outright, because the README's relative links have nothing to resolve against.- The archive is named from the manifest version, which is what the release workflow checks the tag against.
Registration
- Activate on
workspaceContains:**/docforgemcp.{toml,json}plusonLanguage:cpp/onLanguage:c. The index needs no config, so a config-less C++ workspace must still get a server. A workspace with neither never activates the extension. - Register via
contributes.mcpServerDefinitionProviders+vscode.lm.registerMcpServerDefinitionProvider. Never generate.vscode/mcp.json— generated config becomes a file the consumer owns, gitignores, and lets go stale. - One server definition per project root — per discovered
docforgemcp.toml/.json, else per workspace folder — so a monorepo gets one index per project rather than a merged, ambiguous one. - Transport is stdio (
docforge serve). Adding or editing a config invalidates the definition and re-resolves it — no window reload. --configis a global flag and precedes the subcommand: the args are["--config", path, "serve"].docforge serve --config Xisunrecognized arguments. Verify a change here by running the computed command line, not by reading the parser.- A project carrying both config spellings is launched with no
--configat all, so the CLI discovers both fromcwdand refuses. Naming one of them would hide that error. DOCFORGE_DOXYGENpins the binary the extension version-checked. The extension host'sPATHis not the one the probe ran against, so without it the gate vouches for one Doxygen while the server extracts with another. It is a path: the server refuses a bare name, so a resolution must carry where the tool is and never what it was called.- The definition's
versionis a fingerprint, not a version. It hashes the command, the args, the Doxygen, and the contents of each config — content, never mtime, so a touched config does not restart a working server and an edited one does. A restart is a fingerprint bump: there is no restart API, so a nonce moves the version andonDidChangeMcpServerDefinitionsfires. - A config edit must not go through
refresh. The project set is unchanged, so the same-projects check short-circuits;onDidChangefires the definitions event directly. Wiring it torefreshlooks right and silently does nothing. cwdis not a constructor argument ofMcpStdioServerDefinition— assign it afterwards. It is the project root, which is what makes discovery work when there is no--config.- A root carries every config path found in it, not one; two spellings in one directory is the Python side's hard error and the extension must not pre-empt it.
- Two project roots with the same basename are labelled by full path — a label is what the user picks a server by, and two identical ones make a monorepo unusable.
Recognized vs. initialized
These are deliberately different conditions:
| Condition | State |
|---|---|
| workspace recognized, index absent | activate, show Not initialized |
| index present | normal ready / stale / syncing cycle |
| index present but unreadable / fails integrity | Error, never Not-initialized |
Recognition does not depend on a config file — a bare C++ workspace is a valid project. Activation must never require the database either: the state whose purpose is to offer "Initialize project" is exactly the one where no database exists.
First-time indexing of a workspace with a large vendored dependency can take minutes. Show Syncing with progress rather than blocking, and keep answering from whatever is already indexed.
Status surface
- The status item carries the state in icon, text, and colour independently. Colour is never the only signal — only two background colours exist and neither is legible in high-contrast or to a colour-blind user.
- Severity is monotonic while a rebuild runs. Entering Syncing from Stale or Error keeps the warning/error background until the rebuild actually succeeds. A rebuild starting is not evidence anything is fixed.
- The quick-pick menu always lists every entry, ordered by what the current state calls for, with inapplicable entries marked in their detail line. A quick-pick item cannot be greyed out, and omitting entries makes the menu look different every time.
docforge statushashes every in-scope file, so it must never be on a timer. Run it on an event that could have changed the answer — activation, a config or index write, a finished action — and repeatedly only while a rebuild someone else started is in flight, which terminates. An action the extension launched needs no polling at all.- Resolution is not membership. A toolchain map holds entries whose
docforgefailed to resolve; "has a server" is theokflags, and using membership offers a restart that does nothing.
Errors
- Transient: the next filesystem event can clear it and last-known-good is still
served — e.g. a scoped Doxygen run failing on a header saved mid-edit. Output channel
only; no notification, no state change. Reveal with
show(preserveFocus: true)so the cursor is never stolen. - Reported: server not launching, abnormal exit, failed integrity check, missing or out-of-range toolchain. Error state, red background.
- Escalate repeated transients — after N consecutive failures on the same header (default 3), go to Error. A permanently broken header must not look identical to one being typed into. A successful rebuild of that header clears the escalation.
Menu actions
Every entry shells out to the CLI (docforge index --if-missing / --rebuild /
index / status --format json); none is an MCP call. The extension never speaks the
control protocol itself — the CLI delegates to a live server on its own.
- The probe timeout is fatal for a rebuild. A CLI action runs with no timeout — a
killed rebuild reports a working toolchain as broken — and with a large
maxBuffer, since a truncated--format jsonreport looks like a parse failure of a tool that succeeded.
Toolchain resolution
execFile's errorcodeis a string for a spawn failure and a number for a non-zero exit. That is the line between try the next candidate and report an unusable tool; collapsing them makes a brokendoxygenlook absent and suggests installing the one that is already there.- A path the user named is never fallen back from. An unusable
serverPathsetting is an error naming the setting; falling through to adocforgeonPATHleaves them believing their setting is in effect while something else answers. An empty setting string is unset, not a path — VS Code writes""for a cleared string setting. - The supported Doxygen range is restated in TypeScript and guarded by a test that
parses
doxygen.py. Asking the CLI instead would meandocforge status, which hashes every in-scope file — far too expensive for a pre-flight probe. The minimum CLI version is a floor only, raised whenever the extension starts depending on a newer CLI. - Notifications are deduplicated per project and tool by message, and a successful resolution clears the record. Re-announcing the same fault on every rediscovery trains the user to dismiss it unread — invariant 5 lost to noise rather than to silence.