Repository navigation
feat: discover MCP servers in VS code extensions - #504
Conversation
An extension can register MCP servers by calling
`vscode.lm.registerMcpServerDefinitionProvider` instead of shipping an
`mcp.json`. There is no config file at all in that case, so the server is
invisible to the existing extension walk while being just as live in the
editor. Pylance ships one this way.
Discovery is gated on `contributes.mcpServerDefinitionProviders` in the
extension's package.json: VS Code refuses to register an undeclared
provider, so an extension without the key cannot have one, and the
expensive step is skipped for all but the few that do. For those, the
`main` entry point and then the rest of the extension tree are scanned
for `McpStdioServerDefinition` / `McpHttpServerDefinition` constructor
calls, whose arguments are evaluated statically — strings, template
literals, arrays, object literals, `Uri.parse(...)`. Results are keyed by
the JS file and routed through `_validate_servers`, so they get the same
validation and binary-signature checks as file-based configs.
Extraction deliberately under-reports. A computed command or URI drops
the definition rather than guessing; a partially-computed args array
drops the whole argv rather than reporting a half-real one. Template
holes are kept verbatim (`http://localhost:${n}/stream`) because
Pylance picks the port at activation and any concrete value would be
fiction.
Scan cost is bounded at three levels, since extension trees are
attacker-influenceable under `--scan-all-users`: a byte prefilter before
decoding (36 MiB of Pylance bundles, 0.62 MiB actually scanned), an
explicit limit on every source-scanning helper so no single call can
outrun its window, and a per-file cap on definitions processed. Without
the latter two a bundle repeating the class name with a construct that
never closes cost one full-file scan per occurrence — 46s at 5 MiB,
growing quadratically.
Applies family-wide (Cursor, Windsurf, Kiro, Antigravity); the manifest
gate makes it a no-op on forks whose extensions don't use the API.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The provider scan landed enabled family-wide on the reasoning that the
`contributes.mcpServerDefinitionProviders` gate makes it self-limiting.
That reasoning covered cost, not correctness, and it breaks the
convention in `vscode.py` ("VSCode/Copilot-specific features, not
assumed for forks") for the four flags beside it.
Forks install the same extension artifacts as VS Code, so the manifest
key and the constructor call sit on disk identically whether or not the
fork's extension host implements the API. Reading them is therefore
always possible; concluding a server is live is not.
Cursor is a confirmed negative, not merely unverified. It tracks VS Code
1.128.0 — well past the 1.101 that added the API — and still stubs the
registration out in its extension host:
registerMcpServerDefinitionProvider: () => (
warn("registerMcpServerDefinitionProvider is not supported in Cursor"), noop
)
An extension declaring a provider with a literal command, installed in
Cursor, would have been reported as a live MCP server that Cursor never
registers. This machine escaped it only by luck: the one declaring
extension in its Cursor tree is Snyk's own, whose command is computed
and so dropped for lack of a static identifying argument.
`_extension_mcp_providers_enabled` now defaults off, is on only in
`vscode.py` (Pylance verified live), and is explicitly off in
`cursor.py` with the stub quoted — a verified negative is different
information from an unchecked default. Windsurf, Kiro and Antigravity
inherit off pending a check of each extension host. A drift guard
asserts the split so opting a fork in stays a deliberate edit.
Noted for follow-up: Cursor exposes its own
`registerMcpConfigurationProvider`, a separate contribution point this
scan does not cover.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PR Summary by QodoDiscover MCP servers registered by VS Code extensions
AI Description
Diagram
High-Level Assessment
Files changed (6)
|
Code Review by Qodo
1. Comments can invent extension servers
|
| candidate = Path(os.path.normpath(extension_dir / main)) | ||
| if candidate.is_relative_to(extension_dir): | ||
| try: | ||
| if candidate.is_file(): | ||
| found.append(candidate) |
There was a problem hiding this comment.
1. Extension links scan unrelated files 🐞 Bug ⛨ Security
extension_js_files checks paths lexically, but its file checks and the subsequent read follow symlinks. A declared extension can point its main bundle or another JavaScript file outside its directory, causing unrelated contents to be scanned and potentially reported as its servers.
Agent Prompt
## Issue description
Symlinked extension files can redirect provider scanning outside the installed extension directory.
## Fix Focus Areas
- src/agent_scan/agents/vscode/extension_mcp.py[126-154]
- src/agent_scan/agents/vscode/extension_mcp.py[187-204]
- src/agent_scan/agents/vscode/base.py[900-905]
## Recommended Fix
Reject symlink escapes for both manifest-selected and walked files, including a symlinked extension root. Verify containment against resolved paths at read time, and add tests for each route.
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| for seen, match in enumerate(_DEFINITION_RE.finditer(text)): | ||
| if seen >= _MAX_DEFINITIONS_PER_FILE: | ||
| logger.warning("Stopping after %d MCP definition calls in %s", _MAX_DEFINITIONS_PER_FILE, path.as_posix()) | ||
| break | ||
| args = _split_call_args(text, match.end() - 1) |
There was a problem hiding this comment.
2. Comments can invent extension servers 🐞 Bug ≡ Correctness
servers_in_js_file runs its constructor-name regex over the entire source without excluding
comments or string literals. In a declared extension, text such as `//
McpStdioServerDefinition("example", "node")` is parsed and validated as a server despite
constructing nothing.
Agent Prompt
## Issue description
Constructor-shaped text inside comments and strings becomes a reported MCP server.
## Fix Focus Areas
- src/agent_scan/agents/vscode/extension_mcp.py[55-57]
- src/agent_scan/agents/vscode/extension_mcp.py[169-183]
## Recommended Fix
Tokenize enough JavaScript to exclude comments and string literals before accepting constructor matches, and test both cases through provider discovery.
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| for container in (manifest.get("contributes"), manifest): | ||
| if not isinstance(container, dict): | ||
| continue | ||
| declared = container.get(_PROVIDERS_KEY) | ||
| if isinstance(declared, list): | ||
| return [entry for entry in declared if isinstance(entry, dict)] |
There was a problem hiding this comment.
3. Undeclared providers appear as live 🐞 Bug ≡ Correctness
provider_declarations accepts a top-level mcpServerDefinitionProviders key even though the registration gate is the key under contributes. An extension with only that top-level key passes discovery, so matching JavaScript definitions reach the results although VS Code does not recognize its declaration.
Agent Prompt
## Issue description
A top-level manifest key bypasses the declaration gate and yields servers VS Code cannot register.
## Fix Focus Areas
- src/agent_scan/agents/vscode/extension_mcp.py[98-112]
- tests/unit/test_vscode_extension_mcp.py[38-40]
## Recommended Fix
Read provider declarations only from `manifest["contributes"]`, and change the top-level-key test to assert that it does not enable discovery.
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| match = _URI_FACTORY_RE.match(stripped) | ||
| if match is None: | ||
| return stripped | ||
| inner = _split_call_args(stripped, match.end() - 1) | ||
| return inner[0] if inner else stripped |
There was a problem hiding this comment.
4. File-based addresses lose their scheme 🐞 Bug ≡ Correctness
_unwrap_uri extracts the argument of Uri.file() exactly as it does for Uri.parse(), without
converting the path to the URI the factory produces. When an HTTP definition uses
Uri.file("relative.sock"), the reported URL is relative.sock rather than a file URI, and the
remote-server model accepts that string.
Agent Prompt
## Issue description
`Uri.file()` paths are emitted as URLs without the factory's URI conversion.
## Fix Focus Areas
- src/agent_scan/agents/vscode/extension_mcp.py[78-80]
- src/agent_scan/agents/vscode/extension_mcp.py[244-257]
## Recommended Fix
Distinguish `Uri.file()` from `Uri.parse()`; resolve its URI correctly where possible, or leave that definition unresolved rather than emitting the path as a URL.
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| if value is None: | ||
| if not _NUMBER_RE.fullmatch(value_stripped): | ||
| return None | ||
| value = value_stripped | ||
| result[key] = value |
There was a problem hiding this comment.
5. Numeric environment values change 🐞 Bug ≡ Correctness
_js_string_object copies a numeric literal's source spelling instead of converting its JavaScript
value to a string. A stdio definition containing {PORT: 1.0} is inventoried with PORT set to
"1.0", although that numeric value stringifies to "1"; numeric object keys are likewise copied
unchanged.
Agent Prompt
## Issue description
Numeric literal spelling is mistaken for its stringified JavaScript value in extracted environment maps.
## Fix Focus Areas
- src/agent_scan/agents/vscode/extension_mcp.py[303-351]
## Recommended Fix
Convert supported numeric literals according to their evaluated value before emitting values or property keys; leave unsupported numeric forms unresolved, and test `1.0`.
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| i += consumed | ||
| continue | ||
| out.append(nxt) | ||
| i += 2 | ||
| return "".join(out) |
There was a problem hiding this comment.
6. Continued strings gain a newline 🐞 Bug ≡ Correctness
_unescape recognizes a backslash followed by LF as a continuation but does not handle a backslash followed by CRLF. In a JavaScript string such as "a\ followed by CRLF and b", extraction retains the newline in a command, argument, or URL where the evaluated string contains only ab.
Agent Prompt
## Issue description
CRLF line continuations are retained in extracted JavaScript string values.
## Fix Focus Areas
- src/agent_scan/agents/vscode/extension_mcp.py[82-95]
- src/agent_scan/agents/vscode/extension_mcp.py[354-384]
## Recommended Fix
Consume backslash-CRLF and backslash-CR as complete line continuations during unescaping, and add tests for their use in server arguments.
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit b94f9fb. Configure here.
| return found | ||
| except (PermissionError, OSError, ValueError): | ||
| logger.warning("Skipping unreadable extension tree %s", extension_dir.as_posix()) | ||
| return found |
There was a problem hiding this comment.
Special files can hang discovery
High Severity
The JS walk adds every name ending in .js/.cjs/.mjs and _read_text reads it after a size stat only. Unlike the existing mcp.json walk, nothing checks is_file(). A FIFO or a symlink to a device (for example /dev/zero) in an attacker-influenceable extension tree under --scan-all-users blocks forever or grows without bound, stalling the whole discoverer.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit b94f9fb. Configure here.


Note
Medium Risk
New filesystem scanning of extension bundles and JS parsing on MCP discovery; bounded for safety but expands attack surface under
--scan-all-users, while fork gating limits false positives on non-VS Code hosts.Overview
Adds discovery for MCP servers that VS Code extensions register in code via
registerMcpServerDefinitionProvider(nomcp.json), such as Pylance’s HTTP provider.A new
extension_mcpmodule statically scans extension JS forMcpStdioServerDefinition/McpHttpServerDefinitionconstructor calls, gated oncontributes.mcpServerDefinitionProvidersinpackage.json. Extraction is best-effort (literal args only; dynamic values are skipped or left as template holes) and includes caps on file size, walk depth, and scan windows to avoid hostile extension trees under multi-user scans.VSCodeFamilyDiscovereradds_discover_extension_provider_mcp_servers()(with duplicate-name suffixing) behind_extension_mcp_providers_enabled, default off so forks that ship the same extension bytes but stub the API don’t get false positives. VS Code opts in; Cursor documents and keeps the flag off.Unit and discovery tests cover Pylance-like HTTP URLs, stdio configs, manifest gating, install manifest filtering, and fork opt-in behavior.
Reviewed by Cursor Bugbot for commit b94f9fb. Bugbot is set up for automated code reviews on this repo. Configure here.