Skip to content

Commit 3f7a3c8

Browse files
authored
Merge pull request #2144 from dan-niles/fix-mcp-inspector-clipboard-issues
Fix clipboard issues in MCP Inspector extension
2 parents 7ac2a85 + dc4d631 commit 3f7a3c8

4 files changed

Lines changed: 283 additions & 2 deletions

File tree

workspaces/mcp-inspector/mcp-inspector-extension/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111

1212
### Fixed
1313
- **Theme and Accessibility Improvements** — Fixed MCP Inspector styling issues by improving VS Code theme color mapping, synchronizing dark/high-contrast mode behavior, and addressing text visibility problems in the Try It editor
14+
- **Clipboard Support** — Fixed copy, cut, and paste in the MCP Inspector by bridging clipboard access through the extension host so cross-origin iframe inputs and the Server Files / Server Entries copy buttons work reliably
1415

1516
## [0.7.3] - 2026-04-23
1617

workspaces/mcp-inspector/mcp-inspector-extension/scripts/inject-theme.js

Lines changed: 212 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,180 @@ const INDEX_HTML_PATH = path.join(
2323
// Placeholder CSS - will be replaced by dynamic theme injection at runtime
2424
const CUSTOM_CSS = ` <!-- Dynamic Theme Injection Placeholder -->
2525
<script>
26+
// === Clipboard paste bridge ===
27+
// Cross-origin iframes inside VS Code webviews cannot use the Clipboard API
28+
// (microsoft/vscode#129178, #182642). We intercept Cmd/Ctrl+V over paste-capable
29+
// fields and ask the parent webview for the clipboard text, then insert it ourselves.
30+
(function () {
31+
var pasteRequestId = 0;
32+
var pendingPasteRequests = new Map();
33+
34+
function isPasteableInput(el) {
35+
if (!el) return false;
36+
if (el.isContentEditable) return true;
37+
if (el.tagName === 'TEXTAREA') return !el.disabled && !el.readOnly;
38+
if (el.tagName === 'INPUT') {
39+
var t = (el.type || 'text').toLowerCase();
40+
var pasteableTypes = ['text', 'search', 'url', 'email', 'password', 'tel', 'number'];
41+
return pasteableTypes.indexOf(t) !== -1 && !el.disabled && !el.readOnly;
42+
}
43+
return false;
44+
}
45+
46+
function insertTextAt(el, text) {
47+
if (!el || !text) return;
48+
if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') {
49+
// input[type="number"] (and a few others) don't support selection APIs;
50+
// setSelectionRange throws InvalidStateError, which would skip the input event.
51+
var canSelect = el.tagName === 'TEXTAREA' ||
52+
['text', 'search', 'url', 'email', 'password', 'tel'].indexOf((el.type || 'text').toLowerCase()) !== -1;
53+
var start = canSelect && el.selectionStart != null ? el.selectionStart : el.value.length;
54+
var end = canSelect && el.selectionEnd != null ? el.selectionEnd : el.value.length;
55+
var nativeSetter = Object.getOwnPropertyDescriptor(
56+
el.tagName === 'TEXTAREA' ? window.HTMLTextAreaElement.prototype : window.HTMLInputElement.prototype,
57+
'value'
58+
).set;
59+
// Use the native setter so React's onChange fires (React tracks the prior value)
60+
nativeSetter.call(el, el.value.slice(0, start) + text + el.value.slice(end));
61+
if (canSelect) {
62+
var caret = start + text.length;
63+
el.setSelectionRange(caret, caret);
64+
}
65+
el.dispatchEvent(new Event('input', { bubbles: true }));
66+
} else if (el.isContentEditable) {
67+
document.execCommand('insertText', false, text);
68+
}
69+
}
70+
71+
function selectAll(el) {
72+
if (!el) return;
73+
if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') {
74+
try { el.select(); } catch (_) { /* ignore */ }
75+
} else if (el.isContentEditable) {
76+
var range = document.createRange();
77+
range.selectNodeContents(el);
78+
var sel = window.getSelection();
79+
sel.removeAllRanges();
80+
sel.addRange(range);
81+
}
82+
}
83+
84+
function getSelectionContext() {
85+
var ae = document.activeElement;
86+
if (ae && (ae.tagName === 'INPUT' || ae.tagName === 'TEXTAREA')) {
87+
var s = ae.selectionStart;
88+
var en = ae.selectionEnd;
89+
if (s != null && en != null && s !== en) {
90+
return { text: ae.value.slice(s, en), inInput: true, el: ae, start: s, end: en };
91+
}
92+
}
93+
var sel = window.getSelection();
94+
var selText = sel ? sel.toString() : '';
95+
if (selText) return { text: selText, inInput: false };
96+
return null;
97+
}
98+
99+
function deleteSelection(ctx) {
100+
if (!ctx) return;
101+
if (ctx.inInput && ctx.el && !ctx.el.disabled && !ctx.el.readOnly) {
102+
var nativeSetter = Object.getOwnPropertyDescriptor(
103+
ctx.el.tagName === 'TEXTAREA' ? window.HTMLTextAreaElement.prototype : window.HTMLInputElement.prototype,
104+
'value'
105+
).set;
106+
nativeSetter.call(ctx.el, ctx.el.value.slice(0, ctx.start) + ctx.el.value.slice(ctx.end));
107+
ctx.el.setSelectionRange(ctx.start, ctx.start);
108+
ctx.el.dispatchEvent(new Event('input', { bubbles: true }));
109+
} else {
110+
try { document.execCommand('delete'); } catch (_) { /* ignore */ }
111+
}
112+
}
113+
114+
document.addEventListener('keydown', function (e) {
115+
if (!(e.metaKey || e.ctrlKey)) return;
116+
if (e.shiftKey || e.altKey) return;
117+
var k = e.key && e.key.toLowerCase();
118+
119+
// Copy / Cut: work on any selection in the document (form field or UI text).
120+
if (k === 'c' || k === 'x') {
121+
var ctx = getSelectionContext();
122+
if (!ctx || !ctx.text) return;
123+
e.preventDefault();
124+
e.stopPropagation();
125+
if (typeof window.__mcpInspectorWriteClipboard === 'function') {
126+
var write = window.__mcpInspectorWriteClipboard(ctx.text);
127+
if (k === 'x') {
128+
// Defer the delete until after the write resolves to avoid silent data loss.
129+
write.then(function () { deleteSelection(ctx); }, function () { /* keep selection on failure */ });
130+
} else {
131+
write.catch(function () { /* swallow */ });
132+
}
133+
}
134+
return;
135+
}
136+
137+
// Paste / Select-all: only meaningful when a paste-capable field is focused.
138+
var ae = document.activeElement;
139+
if (!isPasteableInput(ae)) return;
140+
if (k === 'v') {
141+
// Prevent the (broken) native paste path; do our own via the parent webview.
142+
e.preventDefault();
143+
e.stopPropagation();
144+
var id = ++pasteRequestId;
145+
pendingPasteRequests.set(id, ae);
146+
setTimeout(function () {
147+
pendingPasteRequests.delete(id);
148+
}, 10000);
149+
window.parent.postMessage({ type: 'mcp-inspector-request-paste', requestId: id }, '*');
150+
} else if (k === 'a') {
151+
// VS Code's webview swallows the native Select All; do it manually.
152+
e.preventDefault();
153+
e.stopPropagation();
154+
selectAll(ae);
155+
}
156+
}, true);
157+
158+
// Bridge for writing to the system clipboard (copy direction).
159+
// Cross-origin iframes can't use navigator.clipboard.writeText() either,
160+
// so we ask the extension host to do it via vscode.env.clipboard.writeText().
161+
var copyRequestId = 0;
162+
var pendingCopyRequests = new Map();
163+
window.__mcpInspectorWriteClipboard = function (text) {
164+
return new Promise(function (resolve, reject) {
165+
var id = ++copyRequestId;
166+
pendingCopyRequests.set(id, { resolve: resolve, reject: reject });
167+
window.parent.postMessage({
168+
type: 'mcp-inspector-request-clipboard-write',
169+
requestId: id,
170+
text: text == null ? '' : String(text)
171+
}, '*');
172+
setTimeout(function () {
173+
if (pendingCopyRequests.has(id)) {
174+
pendingCopyRequests.delete(id);
175+
reject(new Error('Clipboard write timed out'));
176+
}
177+
}, 5000);
178+
});
179+
};
180+
181+
window.addEventListener('message', function (e) {
182+
// Only honour clipboard responses from the parent webview shell.
183+
if (e.source !== window.parent) return;
184+
var msg = e.data;
185+
if (!msg) return;
186+
if (msg.type === 'mcp-inspector-paste-response') {
187+
var target = pendingPasteRequests.get(msg.requestId);
188+
pendingPasteRequests.delete(msg.requestId);
189+
if (target && msg.text) insertTextAt(target, msg.text);
190+
} else if (msg.type === 'mcp-inspector-clipboard-write-result') {
191+
var pending = pendingCopyRequests.get(msg.requestId);
192+
if (!pending) return;
193+
pendingCopyRequests.delete(msg.requestId);
194+
if (msg.ok) pending.resolve();
195+
else pending.reject(new Error(msg.error || 'Clipboard write failed'));
196+
}
197+
});
198+
})();
199+
26200
// Function to convert color (hex or rgb/rgba) to HSL format
27201
function colorToHsl(color) {
28202
let r, g, b;
@@ -69,7 +243,44 @@ const CUSTOM_CSS = ` <!-- Dynamic Theme Injection Placeholder -->
69243
return Math.round(h * 360) + ' ' + Math.round(s * 100) + '% ' + Math.round(l * 100) + '%';
70244
}
71245
72-
// Remove existing theme styles when updating
246+
// Fallback to execCommand when webview blocks navigator.clipboard.writeText.
247+
(function patchClipboard() {
248+
const execCopy = (text) => {
249+
const ta = document.createElement('textarea');
250+
ta.value = text == null ? '' : String(text);
251+
ta.setAttribute('readonly', '');
252+
ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0;pointer-events:none';
253+
document.body.appendChild(ta);
254+
const sel = document.getSelection();
255+
const prevRange = sel && sel.rangeCount > 0 ? sel.getRangeAt(0) : null;
256+
ta.select();
257+
ta.setSelectionRange(0, ta.value.length);
258+
let ok = false;
259+
try { ok = document.execCommand('copy'); } catch (_) { ok = false; }
260+
document.body.removeChild(ta);
261+
if (prevRange && sel) { sel.removeAllRanges(); sel.addRange(prevRange); }
262+
return ok;
263+
};
264+
const native = navigator.clipboard && navigator.clipboard.writeText
265+
? navigator.clipboard.writeText.bind(navigator.clipboard)
266+
: null;
267+
const writeText = async (text) => {
268+
if (native) {
269+
try { return await native(text); } catch (_) {}
270+
}
271+
// VS Code webview path: ask the extension host to write the clipboard.
272+
if (typeof window.__mcpInspectorWriteClipboard === 'function') {
273+
try { await window.__mcpInspectorWriteClipboard(text); return; } catch (_) {}
274+
}
275+
if (execCopy(text)) return;
276+
throw new Error('Clipboard copy failed');
277+
};
278+
if (!navigator.clipboard) {
279+
Object.defineProperty(navigator, 'clipboard', { value: {}, configurable: true });
280+
}
281+
try { navigator.clipboard.writeText = writeText; } catch (_) {}
282+
})();
283+
73284
let themeStyleElement = null;
74285
75286
// Listen for theme color messages from parent

workspaces/mcp-inspector/mcp-inspector-extension/src/MCPInspectorViewProvider.ts

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,60 @@ export class MCPInspectorViewProvider implements vscode.WebviewViewProvider {
2525
enableScripts: WebviewConfig.ENABLE_SCRIPTS,
2626
};
2727

28+
const clipboardBridge = MCPInspectorViewProvider.attachClipboardBridge(webviewView.webview);
29+
webviewView.onDidDispose(() => clipboardBridge.dispose());
2830
webviewView.webview.html = this._getHtmlForWebview(webviewView.webview);
2931
} catch (error) {
3032
Logger.error('Failed to resolve webview view', error);
3133
throw error;
3234
}
3335
}
3436

37+
/**
38+
* Wires up the parent-webview side of the clipboard paste bridge.
39+
* The iframe cannot read the system clipboard directly because it's cross-origin
40+
* to the VS Code webview shell; we relay the request through the extension host.
41+
*/
42+
public static attachClipboardBridge(webview: vscode.Webview): vscode.Disposable {
43+
return webview.onDidReceiveMessage(async (msg) => {
44+
if (!msg) return;
45+
if (msg.type === 'mcp-inspector-request-clipboard-text') {
46+
try {
47+
const text = await vscode.env.clipboard.readText();
48+
webview.postMessage({
49+
type: 'mcp-inspector-clipboard-text',
50+
requestId: msg.requestId,
51+
text,
52+
});
53+
} catch (error) {
54+
Logger.error('Failed to read clipboard for inspector paste', error);
55+
webview.postMessage({
56+
type: 'mcp-inspector-clipboard-text',
57+
requestId: msg.requestId,
58+
text: '',
59+
});
60+
}
61+
} else if (msg.type === 'mcp-inspector-request-clipboard-write') {
62+
try {
63+
await vscode.env.clipboard.writeText(typeof msg.text === 'string' ? msg.text : '');
64+
webview.postMessage({
65+
type: 'mcp-inspector-clipboard-write-result',
66+
requestId: msg.requestId,
67+
ok: true,
68+
});
69+
} catch (error) {
70+
Logger.error('Failed to write clipboard for inspector copy', error);
71+
webview.postMessage({
72+
type: 'mcp-inspector-clipboard-write-result',
73+
requestId: msg.requestId,
74+
ok: false,
75+
error: error instanceof Error ? error.message : String(error),
76+
});
77+
}
78+
}
79+
});
80+
}
81+
3582
/**
3683
* Get HTML content for the webview
3784
*/
@@ -199,7 +246,7 @@ export class MCPInspectorViewProvider implements vscode.WebviewViewProvider {
199246
</style>
200247
</head>
201248
<body>
202-
<iframe id="inspector-iframe" src="${inspectorUrl}" sandbox="allow-scripts allow-forms allow-same-origin"></iframe>
249+
<iframe id="inspector-iframe" src="${inspectorUrl}" sandbox="allow-scripts allow-forms allow-same-origin" allow="clipboard-read; clipboard-write"></iframe>
203250
<script>
204251
(function() {
205252
const iframe = document.getElementById('inspector-iframe');
@@ -344,6 +391,23 @@ export class MCPInspectorViewProvider implements vscode.WebviewViewProvider {
344391
setTimeout(sendThemeColors, 200);
345392
};
346393
394+
// === Clipboard bridge (parent webview side) ===
395+
// Iframe asks us for clipboard read/write -> we ask the extension -> we forward back to iframe.
396+
const vscodeApi = acquireVsCodeApi();
397+
window.addEventListener('message', function(e) {
398+
const msg = e.data;
399+
if (!msg || typeof msg !== 'object') return;
400+
if (msg.type === 'mcp-inspector-request-paste' && e.source === iframe.contentWindow) {
401+
vscodeApi.postMessage({ type: 'mcp-inspector-request-clipboard-text', requestId: msg.requestId });
402+
} else if (msg.type === 'mcp-inspector-request-clipboard-write' && e.source === iframe.contentWindow) {
403+
vscodeApi.postMessage({ type: 'mcp-inspector-request-clipboard-write', requestId: msg.requestId, text: msg.text });
404+
} else if (msg.type === 'mcp-inspector-clipboard-text' && iframe.contentWindow) {
405+
iframe.contentWindow.postMessage({ type: 'mcp-inspector-paste-response', requestId: msg.requestId, text: msg.text }, 'http://localhost:${Ports.CLIENT}');
406+
} else if (msg.type === 'mcp-inspector-clipboard-write-result' && iframe.contentWindow) {
407+
iframe.contentWindow.postMessage({ type: 'mcp-inspector-clipboard-write-result', requestId: msg.requestId, ok: msg.ok, error: msg.error }, 'http://localhost:${Ports.CLIENT}');
408+
}
409+
});
410+
347411
// Listen for VSCode theme changes
348412
const observer = new MutationObserver((mutations) => {
349413
sendThemeColors();

workspaces/mcp-inspector/mcp-inspector-extension/src/extension.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,10 @@ function openInspectorPanel(
119119
dark: vscode.Uri.joinPath(context.extensionUri, 'resources', 'icon-light.svg'),
120120
};
121121

122+
// Wire up the paste bridge so iframe inputs can paste from the system clipboard.
123+
// Tied to panel lifetime to avoid leaking listeners across reopen cycles.
124+
const clipboardBridge = MCPInspectorViewProvider.attachClipboardBridge(currentPanel.webview);
125+
122126
// Set initial loading content
123127
currentPanel.webview.html = provider.getLoadingHtml();
124128

@@ -170,6 +174,7 @@ function openInspectorPanel(
170174
// Handle panel disposal
171175
currentPanel.onDidDispose(
172176
() => {
177+
clipboardBridge.dispose();
173178
currentPanel = undefined;
174179

175180
// Stop the MCP Inspector processes when panel is closed

0 commit comments

Comments
 (0)