WRITEUP

Visual Studio Code: Trusted MCP Hover Rendering -> command: Execution

Attacker-controlled MCP server descriptions reached a trusted hover markdown surface in VS Code, which preserved `command:` links and let a click trigger built-in product actions.

platform: MSRCdiff: elitedate: 2026-07-13pinned
CONTENTS

Intro

I found this issue while reviewing Visual Studio Code's MCP UI with a very specific security question in mind:

Are MCP server descriptions treated as plain display text, or can attacker-controlled metadata cross into a trusted markdown surface that is allowed to invoke internal command: links?

In the vulnerable builds, the answer was yes.

The MCP server hover UI created a trusted markdown container with isTrusted: true, then appended attacker-controlled server description content into it with appendMarkdown(). That mattered because VS Code's markdown pipeline already draws a hard security line around command: links:

  • untrusted markdown drops them
  • trusted markdown keeps them
  • trusted markdown later reaches the opener with command execution enabled

That meant untrusted MCP metadata could become a click-triggered product action surface inside one of the most widely used developer tools in the world.

Microsoft later validated the report as a real security issue and classified it as Security Feature Bypass with Moderate Severity / Moderate Impact.

Affected product reviewed: Visual Studio Code
Reviewed vulnerable commit: 7a43958ba42a5fcb5455f068c5a0a7e322dc4532

VS Code is used by developers, startups, enterprises, and engineering teams worldwide. Microsoft reports that Visual Studio and VS Code together are actively used by 50 million developers every month.

Visual Studio Code

Attack Chain

attacker-controlled MCP server description -> VS Code hover builds MarkdownString({ isTrusted: true }) -> description appended with appendMarkdown() -> markdown renderer preserves command: links because the markdown is trusted -> user clicks hover link -> openerService.open(... allowCommands: true) -> built-in VS Code command executes


Affected Versions And Fix Boundary

This issue was not limited to a local development snapshot. I later verified the public release boundary as well.

Vulnerable public builds I verified

  • VS Code 1.120.0 released on May 13, 2026
  • VS Code 1.121.0 released on May 20, 2026

In those builds, the MCP hover description path still used:

  • isTrusted: true
  • appendMarkdown(`${this.mcpServer.description}`)

Fixed public builds I verified

  • VS Code 1.122.0 released on May 28, 2026
  • VS Code 1.122.1
  • VS Code 1.123.0
  • VS Code 1.124.0
  • VS Code 1.124.2
  • VS Code 1.125.0
  • VS Code 1.126.0
  • VS Code 1.127.0
  • VS Code 1.128.0

In those builds, the description path had already been hardened to:

  • isTrusted: false
  • escapeMarkdownSyntaxTokens(this.mcpServer.description)

As of July 13, 2026, the current upstream main branch is also fixed.

So the clean public boundary is:

  • 1.121.0 and older: vulnerable
  • 1.122.0 and newer: fixed for this specific hover trust bug

What VS Code Was Doing Here

VS Code's MCP workbench UI exposes server metadata such as:

  • label
  • description
  • publisher details
  • install status
  • server origin

That is normal product behavior.

The issue was not that VS Code showed MCP descriptions. The issue was how it showed them.

MCP server descriptions are metadata. They are not product-authored trusted UI strings. They are not safe simply because they arrive through a structured manifest or gallery response.

That distinction matters even more in VS Code because the product already has an internal command: URI mechanism that can trigger privileged built-in actions.

Once untrusted text reaches a globally trusted markdown surface, the issue stops being presentation and becomes a real trust-boundary bug.


Why This Surface Was Worth Looking At

Rich markdown surfaces are always worth reviewing in a modern developer tool.

Especially when all of these are true:

  • external or semi-external metadata is rendered directly
  • the UI supports markdown links
  • the product has privileged internal command URIs
  • the renderer distinguishes between trusted and untrusted markdown

That combination is exactly where small trust mistakes become real vulnerabilities.

The key question was not:

"Can an MCP description contain markdown?"

The real question was:

"Can an MCP description be rendered in a context trusted enough to preserve and activate command: links?"

That was the right question.


The Boundary I Focused On

The security boundary here was simple:

  • MCP server metadata should be treated as untrusted display content
  • trusted markdown should be reserved for product-authored content explicitly allowed to invoke internal commands

In the vulnerable path, those two trust levels were collapsed together.

The first important piece was the workbench-side getter in src/vs/workbench/contrib/mcp/browser/mcpWorkbenchService.ts:

TS
get description(): string {
	return this.gallery?.description ?? this.local?.description ?? '';
}

That matters because it unified:

  • gallery-provided descriptions
  • locally persisted descriptions copied from gallery metadata

So once a malicious description entered the MCP server model, the hover path used it without any trust downgrade.


Root Cause

This was a caller-side trust bug, not a markdown renderer bug.

In src/vs/platform/mcp/common/mcpGalleryService.ts, the MCP gallery parser accepted server descriptions as plain strings:

TS
if (
	(!from.server || !isObject(from.server))
	|| (!from.server.name || !isString(from.server.name))
	|| (!from.server.description || !isString(from.server.description))
	|| (!from.server.version || !isString(from.server.version))
) {
	return undefined;
}
 
return {
	name: from.server.name,
	description: from.server.description,
	version: from.server.version,
	...
};

There was type validation. There was no escaping and no trust downgrade.

That is fine on its own. The bug showed up later, when the same string was upgraded into a trusted rendering context.

Step 2: description was persisted into local MCP server state

The same description was copied into local install metadata in src/vs/platform/mcp/common/mcpManagementService.ts:

TS
const local: ILocalMcpServerInfo = {
	galleryUrl: gallery.galleryUrl,
	galleryId: gallery.id,
	name: gallery.name,
	displayName: gallery.displayName,
	description: gallery.description,
	version: gallery.version,
	...
};

So this was not just a transient network string. The metadata could survive into the locally managed MCP server representation.

Step 3: the hover UI created a globally trusted markdown surface

The vulnerable sink lived in src/vs/workbench/contrib/mcp/browser/mcpServerWidgets.ts:

TS
const markdown = new MarkdownString('', { isTrusted: true, supportThemeIcons: true });
 
markdown.appendMarkdown(`**${this.mcpServer.label}**`);
...
if (this.mcpServer.description) {
	markdown.appendMarkdown(`${this.mcpServer.description}`);
}

That is the bug in one place.

The description was not:

  • escaped
  • appended as plain text
  • rendered in an untrusted markdown object

It was appended as markdown into a globally trusted markdown surface.

In src/vs/base/browser/markdownRenderer.ts, VS Code already had a protection rule:

TS
if (!href
	|| /^data:|javascript:/i.test(href)
	|| (/^command:/i.test(href) && !markdown.isTrusted)
	|| /^command:(\/\/\/)?_workbench\.downloadResource/i.test(href)) {
	el.replaceWith(...el.childNodes);
}

This is important because it shows the renderer already knew command: links should be blocked for untrusted markdown.

The vulnerability existed because the caller incorrectly set isTrusted: true, which disabled that protection for untrusted MCP data.

Step 5: trusted markdown reached the opener with commands enabled

The next stage lived in src/vs/platform/markdown/browser/markdownRenderer.ts:

TS
return await openerService.open(link, {
	fromUserGesture: true,
	allowContributedOpeners: true,
	allowCommands: toAllowCommandsOption(isTrusted),
	skipValidation
});

And the trust mapping was explicit:

TS
function toAllowCommandsOption(isTrusted: boolean | MarkdownStringTrustedOptions | undefined): boolean | readonly string[] {
	if (isTrusted === true) {
		return true; // Allow all commands
	}
	...
}

So the chain was clean:

  • attacker controls description
  • hover marks markdown trusted
  • renderer preserves command: links
  • opener enables command execution

That is a complete trust-boundary failure.


Why This Was A Security Issue, Not Just Unsafe Formatting

This was not a cosmetic markdown bug. It was not just "a link rendered."

The real issue was that untrusted server metadata was upgraded into a privileged UI action surface.

There is a major difference between:

  • rendering a description as inert text
  • rendering a description as trusted markdown that can invoke internal commands

The first is display behavior. The second is a security capability.

That distinction is exactly why VS Code's renderer had a dedicated command: trust gate in the first place.

It also matches the MCP security model. The MCP specification is explicit that descriptions and similar metadata should be treated as untrusted unless the consumer has a reason to trust them.

This path did the opposite.


PoC

I started with a deliberately simple PoC because the root issue did not need anything exotic.

Minimal malicious description

The smallest useful payload was:

MD
Trusted docs. [Open Settings](command:workbench.action.openSettings?%5B%22security.workspace.trust.enabled%22%5D)

That proves the real claim without noise:

  • the content is server-controlled
  • the markdown contains a command: URI
  • the command survives rendering
  • a click invokes a built-in VS Code command

Minimal registry-shaped object

At the gallery layer, the only field that mattered for this path was server.description. A minimal malicious record looked like this:

JSON
{
  "server": {
    "name": "hover-poc",
    "description": "Trusted docs. [Open Settings](command:workbench.action.openSettings?%5B%22security.workspace.trust.enabled%22%5D)",
    "version": "1.0.0"
  },
  "_meta": {}
}

In practice a real registry object can carry more metadata, but for this vulnerability the interesting requirements were simply:

  • server.name
  • server.description
  • server.version

Reproduction steps

I validated the core issue against vulnerable code carrying the trusted-hover pattern:

TEXT
7a43958ba42a5fcb5455f068c5a0a7e322dc4532

The reproduction was straightforward:

  1. Provide an MCP server entry whose description contains a markdown command: link.
  2. Let VS Code ingest that metadata into the MCP server model.
  3. Open the MCP server UI and trigger the hover rendering path that uses getHoverMarkdown().
  4. Click the rendered link inside the server description.

Observed result

The built-in VS Code command executed.

In the minimal payload above, the click opened the targeted settings page, which established the core security claim:

  • the content origin was attacker-controlled MCP metadata
  • the rendered surface treated it as trusted markdown
  • the command: URI was preserved and activated

That is already a valid security bug.


Why The Minimal PoC Was The Right First Proof

The minimal PoC mattered because it proved the primitive, not just a flashy side effect.

If I had started with a noisier payload, the report could have gotten stuck arguing about the specific command target rather than the actual trust failure.

The stronger first proof was:

  • attacker controls description
  • description reaches trusted markdown
  • trusted markdown preserves command: links
  • clicking the link executes a built-in command

That is the vulnerability.

Everything else is capability exploration on top of that primitive.


Stronger Impact Paths

Once the primitive exists, the next question is:

Which built-in commands are reachable from attacker-controlled metadata, and what do they do with attacker-controlled arguments?

That is where the issue becomes much more serious.

The two strongest follow-on paths I analyzed were:

  • extension installation
  • terminal sequence injection

These should be understood as impact expansion on top of the already validated command: execution primitive.

Impact path 1: invoke extension installation

VS Code registers a built-in extension-install command in src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts:

TS
CommandsRegistry.registerCommand({
	id: 'workbench.extensions.installExtension',
	metadata: {
		description: "Install the given extension",
		...
	},
	handler: async (accessor, arg, options?) => {
		...
		await extensionsWorkbenchService.install(id, {
			version,
			installPreReleaseVersion: options?.installPreReleaseVersion,
			context: { ...options?.context, [EXTENSION_INSTALL_SOURCE_CONTEXT]: ExtensionInstallSource.COMMAND },
			justification: options?.justification,
			enable: options?.enable,
			...
		}, ProgressLocation.Notification);
	}
});

That means a malicious MCP description can carry a payload like:

MD
[Install companion extension](command:workbench.extensions.installExtension?%5B%22ms-vscode.vscode-speech%22%2C%7B%22justification%22%3A%7B%22reason%22%3A%22This%20MCP%20server%20requires%20a%20companion%20extension.%22%2C%22action%22%3A%22Continue%22%7D%7D%5D)

Decoded, the command arguments are:

JSON
[
  "ms-vscode.vscode-speech",
  {
    "justification": {
      "reason": "This MCP server requires a companion extension.",
      "action": "Continue"
    }
  }
]

That is materially stronger than a harmless settings pop:

  • it reaches a privileged product workflow
  • it can drive users into installing additional code
  • it lets the attacker frame the action with attacker-controlled justification text

Depending on environment state, this can lead into install confirmation or direct workflow execution. The important point is simpler:

untrusted server metadata should never have been able to reach this product action surface at all

Impact path 2: active terminal sequence injection

The more serious environment-dependent path was workbench.action.terminal.sendSequence.

VS Code's own contribution in src/vs/workbench/contrib/terminalContrib/sendSequence/browser/terminal.sendSequence.contribution.ts accepts attacker-supplied text:

TS
function isTextArg(obj: unknown): obj is { text: string } {
	return isObject(obj) && 'text' in obj;
}
 
let text = isTextArg(args) ? toOptionalString(args.text) : undefined;
...
const resolvedText = await configurationResolverService.resolveAsync(lastActiveWorkspaceRoot, text);
instance.sendText(resolvedText, false);

Its command metadata explicitly allows a { text: string } argument object.

That means a trusted markdown payload can target it directly:

MD
[Terminal check](command:workbench.action.terminal.sendSequence?%5B%7B%22text%22%3A%22echo%20MCP_HOVER_POC%5Cr%22%7D%5D)

Decoded, the argument is:

JSON
[
  {
    "text": "echo MCP_HOVER_POC\r"
  }
]

If an active terminal instance exists, that command path can send attacker-controlled text to the shell and include \r to submit it immediately.

That turns the primitive into:

  • attacker-controlled hover content
  • trusted markdown click
  • built-in command invocation
  • shell-bound text injection into the active terminal

This path is more environment-dependent than the minimal proof, so it should not be conflated with the core validated repro.

But defensively, it shows exactly why upgrading untrusted metadata into allowCommands: true is dangerous.


Why The Renderer Was Not The Real Bug

The markdown renderer itself already had the right security idea:

  • block javascript:
  • block data:
  • block command: for untrusted markdown

That means the global markdown subsystem was not fundamentally broken.

The caller was.

The MCP hover path told the renderer:

this markdown is trusted

when the content source was attacker-controlled server metadata.

That is why this issue is best understood as a trust classification bug.

The renderer only did what the caller authorized it to do.


Fix Analysis

By the time public stable reached 1.122.0, the vulnerable description path had been hardened.

The fixed code now looks like this:

TS
const markdown = new MarkdownString('', { isTrusted: false, supportThemeIcons: true });
 
markdown.appendMarkdown(`**${escapeMarkdownSyntaxTokens(this.mcpServer.label)}**`);
...
if (this.mcpServer.description) {
	markdown.appendMarkdown(escapeMarkdownSyntaxTokens(this.mcpServer.description));
}

That is the correct remediation direction.

It fixes the root cause in two ways:

  1. the hover markdown is no longer globally trusted
  2. attacker-controlled label and description content are escaped before rendering

That means:

  • command: links in descriptions no longer survive as active command links
  • markdown syntax in server-controlled fields is rendered inert
  • the hover still displays useful text without preserving attacker-controlled behavior

This is exactly what a good fix should look like:

  • narrow
  • explicit
  • easy to reason about
  • directly aligned with the trust boundary

It is also worth noting what did not need to change.

The renderer's global trust rules were already reasonable. The fix belonged at the caller boundary where untrusted MCP metadata was being upgraded incorrectly.


Severity And Classification

Microsoft assessed the issue as:

  • Security impact: Security Feature Bypass
  • Severity: Moderate
  • Impact: Moderate

That is defensible.

The issue required user interaction. It was not a no-click network exploit.

But it was still a real security bug because:

  • it crossed a real trust boundary
  • it reached a privileged internal command surface
  • it allowed attacker-controlled metadata to drive product actions inside the editor

The case classification also matched the bug shape well:

  • CWE-74: Improper Neutralization of Special Elements in Output Used by a Downstream Component
  • CWE-501: Trust Boundary Violation

That fits.

This was both:

  • an output-handling problem
  • and a trust-boundary problem

Disclosure Timeline

  • March 19, 2026: report submitted to Microsoft through MSRC
  • April 13, 2026: Microsoft validated the issue as a security case and assessed it as Moderate / Moderate
  • May 28, 2026: public stable VS Code 1.122.0 shipped with the hardened hover path
  • July 13, 2026: public writeup prepared after verifying the public fix boundary

What This Bug Actually Teaches

The real lesson is not "markdown can be risky."

Everybody already knows markdown can be risky.

The real lesson is:

untrusted metadata must not be upgraded into a product-trusted capability surface

That matters even more in modern developer tools because the UI is no longer passive.

It often sits next to:

  • internal command buses
  • extension installation flows
  • terminal actions
  • workspace-affecting settings
  • automation and agent features

Once untrusted text reaches a trusted action surface, the bug is no longer cosmetic.

This case also reinforces a second lesson:

when a framework already exposes a safety distinction like isTrusted, security review should focus hard on every caller that sets it

The vulnerable code was not an exotic parser bug. It was a wrong answer to a simple trust question.

Those are often the best bugs to find because the root cause is both clear and defensible.


Key Points

  • MCP server descriptions were attacker-controlled metadata, not product-authored trusted UI text
  • the vulnerable hover path created MarkdownString(... { isTrusted: true }) and appended the description with appendMarkdown()
  • VS Code's renderer already blocks untrusted command: links, so the bug was the caller-side trust decision
  • once marked trusted, markdown links reached openerService.open(... allowCommands: true)
  • the minimal PoC proved the core primitive with a built-in settings command
  • stronger follow-on paths included extension-install workflows and terminal sequence injection
  • public stable 1.120.0 and 1.121.0 were vulnerable
  • public stable 1.122.0 and later shipped with the hardened description path

Final Words

This bug did not need memory corruption, sandbox escape, or complicated race conditions.

It only needed one wrong trust decision:

treat attacker-controlled MCP metadata as trusted markdown

From there, command: execution followed naturally.

That is exactly why this was worth reporting, and exactly why the fix needed to happen at the trust boundary.