Interaction & Data Flow
An MCP tool returns content with raw HTML tags built from caller- or model-supplied input — clients that render HTML can execute scripts, load attacker images, or exfiltrate via DOM events.
MCP tool output is typed as text or content blocks, but some clients render Markdown and embedded HTML. A `<img onerror=fetch(...)>` snippet returned in a tool's text payload is XSS if the renderer evaluates it. The defense is to either strip HTML before returning, or escape the output's HTML-special characters.
Different MCP clients have different rendering behaviors — some are strict text, some pass Markdown through, some allow embedded HTML for richer UIs. A tool author can't assume strict text. Treating returned content as potentially-rendered HTML is the safe default.
server.tool("format_alert", { msg: z.string() }, async ({ msg }) => { |
return { content: [{ type: "text", text: `<div class="alert">${msg}</div>` }] }; |
}); |
function escapeHtml(s) { |
return s.replace(/[&<>"']/g, (c) => ({ |
"&": "&", "<": "<", ">": ">", '"': """, "'": "'", |
}[c])); |
} |
server.tool("format_alert", { msg: z.string() }, async ({ msg }) => { |
return { content: [{ type: "text", text: `<div class="alert">${escapeHtml(msg)}</div>` }] }; |
}); |
MCPSafe flags tool returns whose text content includes literal HTML tags (`<div>`, `<a>`, `<img>`, `<script>`, `<iframe>`) interpolated with handler parameters via f-string / template literals / concat. Returns that route the parameter through `escapeHtml`, `escape`, `DOMPurify`, or `html.escape` are exempted.
See the full threat catalog for every documented detection.
MCPSafe runs this check — and every other rule in the catalog — on any MCP server you paste in.
Scan now