From affc29e4e78ba853ccb65356ac49ce824afb6b17 Mon Sep 17 00:00:00 2001 From: "cmeans-claude-dev[bot]" <272174644+cmeans-claude-dev[bot]@users.noreply.github.com> Date: Thu, 7 May 2026 17:32:55 -0500 Subject: [PATCH 1/7] fix: SVG clipboard round-trip on Windows + macOS (closes #136) User reported on a QEMU Windows guest that copying an SVG via `clipboard_copy(mime_type="image/svg+xml")` and pasting it back via `clipboard_paste` returned "Clipboard is empty". Awareness diagnostic showed `Image read failed: Unsupported image type: image/svg+xml` in the debug log. Audit found the same bug shape on macOS as a latent gap; Wayland and X11 are correct via CLI tool passthrough. Three layered fixes: 1. `_windows_read` gained an `image/svg+xml` branch that calls `Clipboard::GetData('image/svg+xml')` mirroring the custom format string the writer registers. Forces `[Console]::OutputEncoding = UTF8` so PowerShell's default OEM `OutputEncoding` doesn't mangle the XML on the way back to Python's `_run()` decoder (same class of bug as #129's stdin gap, but on the read direction for this code path). 2. `_macos_read` gained an `image/svg+xml` branch that reads the `public.svg-image` UTI bytes as `NSData` and decodes UTF-8 to a string. `_UTI_TO_MIME` learned `public.svg-image -> image/svg+xml` so `list_clipboard_formats` surfaces SVG with its MIME type rather than the raw UTI. 3. `clipboard_paste`'s auto-dispatch in `server.py` no longer routes `image/svg+xml` through the binary `read_clipboard_image` path (which raster readers reject). The dispatch now splits image formats into `raster_formats` (PNG/JPEG/etc., routed to image read) and `svg_formats` (routed to text read). When both are present, raster wins (returns a viewable Image to the host model). When only SVG is present, the markup is returned in a ```svg fenced code block. Tests: 6 new (`test_windows_read_svg_uses_data_object_get_data`, `test_macos_read_svg_uses_public_svg_image_uti`, `test_macos_uti_to_mime_maps_public_svg_image`, `test_clipboard_paste_returns_svg_as_fenced_text_when_only_svg_present`, `test_clipboard_paste_prefers_raster_over_svg_when_both_present`, `test_clipboard_paste_does_not_route_svg_through_image_read_path`). Test count: 610 -> 616. Local verification: pytest 616 passed / 19 deselected / 5 xfailed; ruff clean; mypy clean. The user reported the bug from a QEMU Windows guest; post-merge they'll re-test the round-trip on the same host with the patched wheel to confirm the live PowerShell behavior matches the unit-test contract. A separate follow-up issue (#137) tracks designing a comprehensive Windows integration test suite so future Windows-only bugs surface in CI rather than via manual user testing. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 22 ++++++ src/mcp_clipboard/clipboard.py | 38 ++++++++++ src/mcp_clipboard/server.py | 97 ++++++++++++++++--------- tests/test_server.py | 126 +++++++++++++++++++++++++++++++++ 4 files changed, 250 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e406410..2482b0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,28 @@ All notable changes to this project will be documented here. ## [Unreleased] +### Fixed +- SVG clipboard round-trip on Windows and macOS: copying an SVG via + `clipboard_copy(mime_type="image/svg+xml")` and then pasting it back + no longer returns "Clipboard is empty." Three layered gaps closed: + (1) `_windows_read` gained an `image/svg+xml` branch that calls + `Clipboard::GetData('image/svg+xml')` (mirroring the custom format + string the writer registers) and forces UTF-8 stdout encoding so + the round-tripped XML doesn't get mangled by PowerShell's default + OEM `OutputEncoding`. (2) `_macos_read` gained an `image/svg+xml` + branch that reads `public.svg-image` UTI bytes as UTF-8, and + `_UTI_TO_MIME` learned `public.svg-image -> image/svg+xml` so + `list_clipboard_formats` surfaces SVG with its MIME type rather + than the raw UTI. (3) `clipboard_paste`'s auto-dispatch no longer + routes `image/svg+xml` through the binary `read_clipboard_image` + path (which raster readers reject as "Unsupported image type"); + SVG-only clipboards are now read as text and returned in an + ```svg fenced code block. Raster images still take precedence + over SVG when both are present, so a typical "screenshot + SVG + source" clipboard still returns the viewable PNG as before. The + Linux backends (Wayland/X11) were already correct via passthrough + on `wl-paste --type` / `xclip -target`. + ## [2.6.0] - 2026-05-07 ### Fixed diff --git a/src/mcp_clipboard/clipboard.py b/src/mcp_clipboard/clipboard.py index f3a48ba..0dacba3 100644 --- a/src/mcp_clipboard/clipboard.py +++ b/src/mcp_clipboard/clipboard.py @@ -361,6 +361,21 @@ async def _macos_read(mime_type: str, selection: str = "clipboard") -> str: ) return await _run(["osascript", "-e", script], allow_empty_exit=False) + if mime_type == "image/svg+xml": + # SVG is written via _macos_write_typed under the "public.svg-image" + # UTI (matching the Inkscape/Figma/browser convention). Reading it + # back uses the same UTI; the bytes are UTF-8 XML, decoded as text. + script = ( + 'use framework "AppKit"\n' + "set pb to current application's NSPasteboard's generalPasteboard()\n" + 'set svgData to pb\'s dataForType:"public.svg-image"\n' + 'if svgData is missing value then return ""\n' + "set svgString to (current application's NSString's alloc()'s " + "initWithData:svgData encoding:(current application's NSUTF8StringEncoding))\n" + "return svgString as text" + ) + return await _run(["osascript", "-e", script], allow_empty_exit=False) + # Unsupported MIME type — signal "not available" rather than returning wrong content return "" @@ -370,6 +385,7 @@ async def _macos_read(mime_type: str, selection: str = "clipboard") -> str: "public.utf8-plain-text": "text/plain", "public.plain-text": "text/plain", "public.rtf": "text/rtf", + "public.svg-image": "image/svg+xml", "public.png": "image/png", "public.tiff": "image/tiff", "public.jpeg": "image/jpeg", @@ -446,6 +462,28 @@ async def _windows_read(mime_type: str, selection: str = "clipboard") -> str: allow_empty_exit=False, ) + if mime_type == "image/svg+xml": + # SVG is written via _windows_write_typed as a custom format string + # 'image/svg+xml' on the DataObject. Reading it back uses the same + # format string. Output encoding matters here too: PowerShell's + # default OutputEncoding can mangle the UTF-8 SVG markup on the way + # back to Python's _run() decoder. Force UTF-8 on stdout. + script = ( + "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; " + "Add-Type -AssemblyName System.Windows.Forms; " + "$data = [System.Windows.Forms.Clipboard]::GetData('image/svg+xml'); " + "if ($data -eq $null) { return }; $data" + ) + return await _run( + [ + "powershell", + "-NoProfile", + "-Command", + script, + ], + allow_empty_exit=False, + ) + # Unsupported MIME type — signal "not available" rather than returning wrong content return "" diff --git a/src/mcp_clipboard/server.py b/src/mcp_clipboard/server.py index b5a5e69..6e57a5a 100644 --- a/src/mcp_clipboard/server.py +++ b/src/mcp_clipboard/server.py @@ -319,43 +319,74 @@ async def clipboard_paste( except ClipboardError as e: logger.debug("RTF clipboard read failed: %s", e) - # If no text content at all, check whether the clipboard holds binary data + # If no text content at all, list formats and dispatch by what's there. if not content.strip(): try: formats = await list_clipboard_formats(selection) - image_formats = [f for f in formats if f.startswith("image/")] - if image_formats: - # Prefer PNG (match by base type to handle parameter suffixes) - mime = next( - (f for f in image_formats if base_mime_type(f) == "image/png"), - image_formats[0], - ) - try: - data = await read_clipboard_image(mime, selection) - if data: - fmt = base_mime_type(mime).split("/", 1)[1].lower() - if fmt not in _IMAGE_SUBTYPE_ALLOWLIST: - fmt = "png" - return Image(data=data, format=fmt) - except ClipboardSizeError as e: - return f"Clipboard image too large to return: {e}" - except ClipboardError as e: - logger.debug("Image read failed: %s", e) - # Non-image binary (audio/video) — report but can't return - binary = [ - f - for f in formats - if (f.startswith(_BINARY_MIME_PREFIXES) or f in _BINARY_MIME_EXACT) - and not f.startswith("image/") - ] - if binary: - fmt_list = ", ".join(binary) - return ( - f"Clipboard contains binary data ({fmt_list}) which cannot be " - f"returned as text. Audio and video are not supported." - ) except ClipboardError: - pass + formats = [] + + # Split image/* formats: SVG is text-readable XML, everything else + # is raster bytes. Routing SVG through the raster image path was + # the read-side half of #129's sibling SVG round-trip bug. + raster_formats = [ + f + for f in formats + if f.startswith("image/") and base_mime_type(f) not in _TEXT_READABLE_MIMES + ] + svg_formats = [f for f in formats if base_mime_type(f) == "image/svg+xml"] + + # Prefer raster (returns a viewable Image object the host model + # can analyze visually) over SVG markup (returned as text below). + if raster_formats: + mime = next( + (f for f in raster_formats if base_mime_type(f) == "image/png"), + raster_formats[0], + ) + try: + data = await read_clipboard_image(mime, selection) + if data: + fmt = base_mime_type(mime).split("/", 1)[1].lower() + if fmt not in _IMAGE_SUBTYPE_ALLOWLIST: + fmt = "png" + return Image(data=data, format=fmt) + except ClipboardSizeError as e: + return f"Clipboard image too large to return: {e}" + except ClipboardError as e: + logger.debug("Image read failed: %s", e) + + # SVG-only clipboards: read the markup as text and return a fenced + # block so the host model can inspect the source. + if svg_formats: + try: + svg_text = await read_clipboard(svg_formats[0], selection) + if svg_text.strip(): + truncated = len(svg_text) > _MAX_CONTENT_CHARS + display = svg_text[:_MAX_CONTENT_CHARS] + fence = _safe_code_fence(display) + result = ( + f"Clipboard contains SVG ({svg_formats[0]}):\n\n" + f"{fence}svg\n{display}\n{fence}" + ) + if truncated: + result += f"\n\n... [truncated at {_MAX_CONTENT_CHARS:,} characters]" + return result + except ClipboardError as e: + logger.debug("SVG clipboard read failed: %s", e) + + # Non-image binary (audio/video): report but cannot return. + binary = [ + f + for f in formats + if (f.startswith(_BINARY_MIME_PREFIXES) or f in _BINARY_MIME_EXACT) + and not f.startswith("image/") + ] + if binary: + fmt_list = ", ".join(binary) + return ( + f"Clipboard contains binary data ({fmt_list}) which cannot be " + f"returned as text. Audio and video are not supported." + ) return _format_non_tabular(content) diff --git a/tests/test_server.py b/tests/test_server.py index 36e1040..22c25a0 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -2869,6 +2869,132 @@ async def test_windows_write_typed_unsupported_lists_svg(): await _windows_write_typed("data", "text/csv") +# --------------------------------------------------------------------------- +# SVG round-trip read paths +# +# SVG is XML text but conventionally uses image/* MIME type, so it falls +# into a gap: dispatched as binary by `image/*` filters but rejected by +# raster image readers as "Unsupported image type". The fix routes SVG +# through a text-read branch on each backend and through a dedicated +# `clipboard_paste` SVG-fallback branch when no raster image is available. +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_windows_read_svg_uses_data_object_get_data(): + """_windows_read for image/svg+xml calls Clipboard::GetData('image/svg+xml') + via PowerShell, mirroring the custom-format string used on the write side.""" + from mcp_clipboard.clipboard import _windows_read + + with patch( + "mcp_clipboard.clipboard._run", new=AsyncMock(return_value=_SAMPLE_SVG) + ) as mock: + result = await _windows_read("image/svg+xml") + + assert result == _SAMPLE_SVG + cmd = mock.call_args[0][0] + script = " ".join(cmd) + assert "GetData('image/svg+xml')" in script + # OutputEncoding directive prevents OEM mojibake on the way back. + assert "[Console]::OutputEncoding" in script + assert "UTF8" in script + + +@pytest.mark.asyncio +async def test_macos_read_svg_uses_public_svg_image_uti(): + """_macos_read for image/svg+xml reads the public.svg-image UTI as NSData + and decodes UTF-8 to a string.""" + from mcp_clipboard.clipboard import _macos_read + + with patch( + "mcp_clipboard.clipboard._run", new=AsyncMock(return_value=_SAMPLE_SVG) + ) as mock: + result = await _macos_read("image/svg+xml") + + assert result == _SAMPLE_SVG + script = mock.call_args[0][0][2] + assert 'dataForType:"public.svg-image"' in script + assert "NSUTF8StringEncoding" in script + + +def test_macos_uti_to_mime_maps_public_svg_image(): + """list_clipboard_formats on macOS surfaces SVG as image/svg+xml, not as + the raw public.svg-image UTI.""" + from mcp_clipboard.clipboard import _UTI_TO_MIME + + assert _UTI_TO_MIME.get("public.svg-image") == "image/svg+xml" + + +@pytest.mark.asyncio +async def test_clipboard_paste_returns_svg_as_fenced_text_when_only_svg_present(): + """When the clipboard has only image/svg+xml (no text, no raster image), + clipboard_paste returns the SVG markup in an ```svg fenced code block.""" + with ( + patch("mcp_clipboard.server.read_clipboard", new=AsyncMock(return_value="")), + patch( + "mcp_clipboard.server.list_clipboard_formats", + new=AsyncMock(return_value=["image/svg+xml"]), + ), + patch( + "mcp_clipboard.server.read_clipboard", + new=AsyncMock(side_effect=lambda mime, sel: _SAMPLE_SVG if mime == "image/svg+xml" else ""), + ), + ): + result = await clipboard_paste() + + assert isinstance(result, str) + assert "```svg" in result + assert _SAMPLE_SVG in result + assert "Clipboard contains SVG" in result + + +@pytest.mark.asyncio +async def test_clipboard_paste_prefers_raster_over_svg_when_both_present(): + """When the clipboard has both image/png and image/svg+xml, clipboard_paste + returns the rasterized PNG as an Image, not the SVG markup as text.""" + png_bytes = b"\x89PNG\r\n\x1a\n" + b"\x00" * 32 # plausible PNG-ish bytes + + with ( + patch("mcp_clipboard.server.read_clipboard", new=AsyncMock(return_value="")), + patch( + "mcp_clipboard.server.list_clipboard_formats", + new=AsyncMock(return_value=["image/png", "image/svg+xml"]), + ), + patch( + "mcp_clipboard.server.read_clipboard_image", + new=AsyncMock(return_value=png_bytes), + ), + ): + result = await clipboard_paste() + + assert isinstance(result, Image) + + +@pytest.mark.asyncio +async def test_clipboard_paste_does_not_route_svg_through_image_read_path(): + """Regression: the auto-dispatch must NOT call read_clipboard_image with + 'image/svg+xml'. That was the original Windows breakage -- the binary + reader rejected SVG as 'Unsupported image type' and clipboard_paste + silently returned 'Clipboard is empty'.""" + image_read = AsyncMock() # would record the call if it happened + + with ( + patch("mcp_clipboard.server.read_clipboard", new=AsyncMock(return_value="")), + patch( + "mcp_clipboard.server.list_clipboard_formats", + new=AsyncMock(return_value=["image/svg+xml"]), + ), + patch("mcp_clipboard.server.read_clipboard_image", new=image_read), + patch( + "mcp_clipboard.server.read_clipboard", + new=AsyncMock(side_effect=lambda mime, sel: _SAMPLE_SVG if mime == "image/svg+xml" else ""), + ), + ): + await clipboard_paste() + + image_read.assert_not_called() + + # --------------------------------------------------------------------------- # Windows UTF-8 stdin encoding (#129) # From 6b5d178828486b54fa0ca374de496452146294f6 Mon Sep 17 00:00:00 2001 From: "cmeans-claude-dev[bot]" <272174644+cmeans-claude-dev[bot]@users.noreply.github.com> Date: Thu, 7 May 2026 17:36:15 -0500 Subject: [PATCH 2/7] style: ruff-format the new SVG round-trip tests CI lint job's `ruff format --check` flagged tests/test_server.py for whitespace formatting in the new SVG-test block. `uv run ruff format src/ tests/` auto-fix; no logic change. --- tests/test_server.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/test_server.py b/tests/test_server.py index 22c25a0..e53d71d 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -2886,9 +2886,7 @@ async def test_windows_read_svg_uses_data_object_get_data(): via PowerShell, mirroring the custom-format string used on the write side.""" from mcp_clipboard.clipboard import _windows_read - with patch( - "mcp_clipboard.clipboard._run", new=AsyncMock(return_value=_SAMPLE_SVG) - ) as mock: + with patch("mcp_clipboard.clipboard._run", new=AsyncMock(return_value=_SAMPLE_SVG)) as mock: result = await _windows_read("image/svg+xml") assert result == _SAMPLE_SVG @@ -2906,9 +2904,7 @@ async def test_macos_read_svg_uses_public_svg_image_uti(): and decodes UTF-8 to a string.""" from mcp_clipboard.clipboard import _macos_read - with patch( - "mcp_clipboard.clipboard._run", new=AsyncMock(return_value=_SAMPLE_SVG) - ) as mock: + with patch("mcp_clipboard.clipboard._run", new=AsyncMock(return_value=_SAMPLE_SVG)) as mock: result = await _macos_read("image/svg+xml") assert result == _SAMPLE_SVG @@ -2937,7 +2933,9 @@ async def test_clipboard_paste_returns_svg_as_fenced_text_when_only_svg_present( ), patch( "mcp_clipboard.server.read_clipboard", - new=AsyncMock(side_effect=lambda mime, sel: _SAMPLE_SVG if mime == "image/svg+xml" else ""), + new=AsyncMock( + side_effect=lambda mime, sel: _SAMPLE_SVG if mime == "image/svg+xml" else "" + ), ), ): result = await clipboard_paste() @@ -2987,7 +2985,9 @@ async def test_clipboard_paste_does_not_route_svg_through_image_read_path(): patch("mcp_clipboard.server.read_clipboard_image", new=image_read), patch( "mcp_clipboard.server.read_clipboard", - new=AsyncMock(side_effect=lambda mime, sel: _SAMPLE_SVG if mime == "image/svg+xml" else ""), + new=AsyncMock( + side_effect=lambda mime, sel: _SAMPLE_SVG if mime == "image/svg+xml" else "" + ), ), ): await clipboard_paste() From da0b1bbc5e5e05d6dffe3f45ddd9f70323a76aed Mon Sep 17 00:00:00 2001 From: "cmeans-claude-dev[bot]" <272174644+cmeans-claude-dev[bot]@users.noreply.github.com> Date: Thu, 7 May 2026 17:38:41 -0500 Subject: [PATCH 3/7] test: add SVG truncation + read-error coverage (codecov/patch fix) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new tests cover lines 372 and 374-375 of `clipboard_paste`'s SVG branch that codecov/patch flagged as uncovered on f574844: - `test_clipboard_paste_truncates_oversized_svg` — sends an SVG larger than `_MAX_CONTENT_CHARS` and asserts the truncation marker appears in the response. - `test_clipboard_paste_logs_and_falls_through_when_svg_read_errors` — makes `read_clipboard` raise `ClipboardError` for the SVG mime and asserts `clipboard_paste` falls through to "Clipboard is empty" rather than crashing. Test count: 616 -> 618. --- tests/test_server.py | 53 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/tests/test_server.py b/tests/test_server.py index e53d71d..ac57c4d 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -2968,6 +2968,59 @@ async def test_clipboard_paste_prefers_raster_over_svg_when_both_present(): assert isinstance(result, Image) +@pytest.mark.asyncio +async def test_clipboard_paste_truncates_oversized_svg(): + """SVG longer than _MAX_CONTENT_CHARS is truncated with a marker line, + matching the RTF fallback's truncation behavior.""" + from mcp_clipboard.server import _MAX_CONTENT_CHARS + + big_svg = ( + '' + + ("" * (_MAX_CONTENT_CHARS // 7)) + + "" + ) + assert len(big_svg) > _MAX_CONTENT_CHARS + + with ( + patch( + "mcp_clipboard.server.list_clipboard_formats", + new=AsyncMock(return_value=["image/svg+xml"]), + ), + patch( + "mcp_clipboard.server.read_clipboard", + new=AsyncMock(side_effect=lambda mime, sel: big_svg if mime == "image/svg+xml" else ""), + ), + ): + result = await clipboard_paste() + + assert isinstance(result, str) + assert "[truncated at" in result + + +@pytest.mark.asyncio +async def test_clipboard_paste_logs_and_falls_through_when_svg_read_errors(): + """If the SVG text read raises ClipboardError, the auto-dispatch logs and + falls through to the binary-format check rather than crashing.""" + + def _raise_on_svg(mime: str, sel: str) -> str: + if mime == "image/svg+xml": + raise ClipboardError("boom") + return "" + + with ( + patch( + "mcp_clipboard.server.list_clipboard_formats", + new=AsyncMock(return_value=["image/svg+xml"]), + ), + patch("mcp_clipboard.server.read_clipboard", new=AsyncMock(side_effect=_raise_on_svg)), + ): + result = await clipboard_paste() + + # SVG read raised; no raster, no binary, so we end up at "Clipboard is empty." + assert isinstance(result, str) + assert "Clipboard is empty" in result + + @pytest.mark.asyncio async def test_clipboard_paste_does_not_route_svg_through_image_read_path(): """Regression: the auto-dispatch must NOT call read_clipboard_image with From 4277e5a6133099bb9a17d383986db55cbf08cb22 Mon Sep 17 00:00:00 2001 From: "cmeans-claude-dev[bot]" <272174644+cmeans-claude-dev[bot]@users.noreply.github.com> Date: Thu, 7 May 2026 17:58:20 -0500 Subject: [PATCH 4/7] test: drop dead-shadowed read_clipboard patches in two SVG tests (QA Round 1 F2) QA reviewer flagged a `with patch()` group that double-targeted `mcp_clipboard.server.read_clipboard`. When two patches in the same `with` group target the same symbol, only the last one is active in the body -- the first is created, immediately shadowed, and torn down on exit. Both affected tests pass either way because the second patch's `side_effect` lambda already handles all branches, but the redundant first patch is noise that bites later if someone removes the second patch and assumes the first one was load-bearing. Affected tests: - test_clipboard_paste_returns_svg_as_fenced_text_when_only_svg_present - test_clipboard_paste_does_not_route_svg_through_image_read_path Two-line cleanup. test_clipboard_paste_prefers_raster_over_svg_when_both_present is unaffected (its three patches target three different symbols). Local verification: 618 tests still pass; ruff + format clean. --- tests/test_server.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/test_server.py b/tests/test_server.py index ac57c4d..8cb47e1 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -2926,7 +2926,6 @@ async def test_clipboard_paste_returns_svg_as_fenced_text_when_only_svg_present( """When the clipboard has only image/svg+xml (no text, no raster image), clipboard_paste returns the SVG markup in an ```svg fenced code block.""" with ( - patch("mcp_clipboard.server.read_clipboard", new=AsyncMock(return_value="")), patch( "mcp_clipboard.server.list_clipboard_formats", new=AsyncMock(return_value=["image/svg+xml"]), @@ -3030,7 +3029,6 @@ async def test_clipboard_paste_does_not_route_svg_through_image_read_path(): image_read = AsyncMock() # would record the call if it happened with ( - patch("mcp_clipboard.server.read_clipboard", new=AsyncMock(return_value="")), patch( "mcp_clipboard.server.list_clipboard_formats", new=AsyncMock(return_value=["image/svg+xml"]), From 47703a1a1f984d6a1ae3e883bdb8861a3a6baf44 Mon Sep 17 00:00:00 2001 From: "cmeans-claude-dev[bot]" <272174644+cmeans-claude-dev[bot]@users.noreply.github.com> Date: Thu, 7 May 2026 18:57:49 -0500 Subject: [PATCH 5/7] fix(windows): suppress PowerShell default-formatter trailing CRLF on clipboard reads (folded into #138) Live testing of the SVG round-trip fix on a QEMU Windows guest revealed a small artifact: the read-back SVG had `\r\n` glued onto the end (visible as a blank line between `` and the closing fence in `clipboard_paste`'s ```svg block). The original write content didn't have that trailing newline, so this was an artifact of the read-side script structure. Root cause: every `_windows_read` branch was ending its PowerShell script with a bare expression that flows through PowerShell's default Out-Default formatter, which appends a trailing CRLF on Windows. Affected scripts: - text/html: ended with `[Clipboard]::GetData(DataFormats::Html)` as bare expr. - text/plain: was `Get-Clipboard` (also returns array of lines, formatter rejoins, default newline). - text/rtf: ended with bare `$data` expression. - image/svg+xml (added in this PR): same bare `$data` shape. Fix: every branch now ends with `[Console]::Write($data)` (or `[Console]::Write((Get-Clipboard -Raw))` for text/plain). `[Console]::Write` writes the string byte-for-byte with no newline appended. `Get-Clipboard -Raw` returns the entire clipboard as a single string preserving original line endings, rather than the array-of-lines that the default formatter would rejoin. `[Console]::Write` tolerates `$null` (no-op) so the prior `if ($data -eq $null) { return }` guards are no longer necessary; the scripts are simpler. The text/html, text/plain, and text/rtf branches were already affected by the same trailing-CRLF artifact pre-existing this PR. The SVG fix made it visible on the QEMU guest because SVG content is XML where surrounding whitespace is conspicuous in the wrapped output. Folding the cleanup into all four branches at once gives uniform behavior across the Windows read paths. Note: `OutputEncoding = UTF8` is still only set on the SVG branch. The text/html / text/plain / text/rtf branches still rely on PowerShell's default OutputEncoding (CP437 on US English Windows) which mangles non-ASCII content. That's the broader read-direction concern tracked separately as #132; the CRLF fix here is orthogonal and doesn't depend on the encoding sweep. 4 new unit tests (`test_windows_read_html_uses_console_write_to_suppress_trailing_crlf`, `test_windows_read_plain_uses_console_write_with_get_clipboard_raw`, `test_windows_read_rtf_uses_console_write_to_suppress_trailing_crlf`, `test_windows_read_svg_uses_console_write_to_suppress_trailing_crlf`) plus a `_capture_last_powershell_script` test helper that reuses the mock pattern. Each test asserts the script contains `[Console]::Write(` and does NOT end with bare `$data`. Test count: 618 -> 622. Local verification: pytest 622 passed / 19 deselected / 5 xfailed; ruff/format/mypy clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 15 ++++++++ src/mcp_clipboard/clipboard.py | 33 ++++++++++++----- tests/test_server.py | 65 ++++++++++++++++++++++++++++++++++ 3 files changed, 104 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2482b0d..ba04bf4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,21 @@ All notable changes to this project will be documented here. source" clipboard still returns the viewable PNG as before. The Linux backends (Wayland/X11) were already correct via passthrough on `wl-paste --type` / `xclip -target`. +- Trailing CRLF on Windows clipboard reads. Live testing on a QEMU + Windows guest after the SVG fix landed showed a `\r\n` sequence + glued onto the read-back payload (visible as a blank line between + the closing `` and the closing fence in `clipboard_paste`'s + output). Root cause: every `_windows_read` branch was ending its + PowerShell script with a bare expression (`$data` for HTML/RTF/SVG, + `Get-Clipboard` for text/plain), which flows through PowerShell's + default Out-Default formatter and gets a trailing CRLF appended. + All four branches now end with `[Console]::Write($data)` (or + `[Console]::Write((Get-Clipboard -Raw))` for the text/plain path, + using `-Raw` so multi-line clipboard content is preserved as a + single string with original line endings rather than reassembled + by the formatter). `[Console]::Write` writes the value byte-for-byte + with no implicit newline; passing `$null` is a no-op so the prior + `if ($data -eq $null) { return }` guards are also gone. ## [2.6.0] - 2026-05-07 diff --git a/src/mcp_clipboard/clipboard.py b/src/mcp_clipboard/clipboard.py index 0dacba3..9b77b45 100644 --- a/src/mcp_clipboard/clipboard.py +++ b/src/mcp_clipboard/clipboard.py @@ -418,29 +418,44 @@ async def _macos_list_formats(selection: str = "clipboard") -> list[str]: async def _windows_read(mime_type: str, selection: str = "clipboard") -> str: + # Every branch below ends in `[Console]::Write($data)` rather than letting + # the captured value flow to PowerShell's default Out-Default formatter. + # The default formatter appends a trailing CRLF to whatever it prints, so + # bare `$data` at the end of a script returns content + `\r\n` to Python's + # _run() decoder. `[Console]::Write` writes the string as-is with no + # newline appended, preserving byte-exact round-trip for clipboard + # content that doesn't have its own trailing newline. `[Console]::Write` + # also tolerates `$null` (no-op) so the prior `if ($data -eq $null)` + # guards are no longer necessary. _reject_non_clipboard_selection(selection, "Windows") if mime_type == "text/html": - # PowerShell: Get HTML format from clipboard script = ( - "[System.Windows.Forms.Clipboard]::GetData([System.Windows.Forms.DataFormats]::Html)" + "Add-Type -AssemblyName System.Windows.Forms; " + "$data = [System.Windows.Forms.Clipboard]::GetData(" + "[System.Windows.Forms.DataFormats]::Html); " + "[Console]::Write($data)" ) return await _run( [ "powershell", "-NoProfile", "-Command", - f"Add-Type -AssemblyName System.Windows.Forms; {script}", + script, ], allow_empty_exit=False, ) if mime_type == "text/plain": + # `Get-Clipboard -Raw` returns the entire clipboard as a single string + # preserving original line endings; without -Raw it returns an array + # of lines and the default formatter rejoins with \n (losing CRLF info + # and adding a trailing newline). return await _run( [ "powershell", "-NoProfile", "-Command", - "Get-Clipboard", + "[Console]::Write((Get-Clipboard -Raw))", ], allow_empty_exit=False, ) @@ -450,7 +465,7 @@ async def _windows_read(mime_type: str, selection: str = "clipboard") -> str: "Add-Type -AssemblyName System.Windows.Forms; " "$data = [System.Windows.Forms.Clipboard]::GetData(" "[System.Windows.Forms.DataFormats]::Rtf); " - "if ($data -eq $null) { return }; $data" + "[Console]::Write($data)" ) return await _run( [ @@ -465,14 +480,14 @@ async def _windows_read(mime_type: str, selection: str = "clipboard") -> str: if mime_type == "image/svg+xml": # SVG is written via _windows_write_typed as a custom format string # 'image/svg+xml' on the DataObject. Reading it back uses the same - # format string. Output encoding matters here too: PowerShell's - # default OutputEncoding can mangle the UTF-8 SVG markup on the way - # back to Python's _run() decoder. Force UTF-8 on stdout. + # format string. OutputEncoding=UTF8 prevents PowerShell's default + # OEM OutputEncoding from mangling the UTF-8 SVG markup on the way + # back to Python's _run() decoder. script = ( "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; " "Add-Type -AssemblyName System.Windows.Forms; " "$data = [System.Windows.Forms.Clipboard]::GetData('image/svg+xml'); " - "if ($data -eq $null) { return }; $data" + "[Console]::Write($data)" ) return await _run( [ diff --git a/tests/test_server.py b/tests/test_server.py index 8cb47e1..ac0ae57 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -2880,6 +2880,71 @@ async def test_windows_write_typed_unsupported_lists_svg(): # --------------------------------------------------------------------------- +@pytest.mark.asyncio +async def test_windows_read_html_uses_console_write_to_suppress_trailing_crlf(): + """`_windows_read` for text/html ends with `[Console]::Write($data)` rather + than a bare `$data` expression, so PowerShell's default Out-Default + formatter doesn't append a trailing CRLF to the returned content. Same + pattern is required across every Windows read branch (#138 round 3 follow-up + after live-VM testing showed `\\r\\n` glued onto the SVG payload).""" + from mcp_clipboard.clipboard import _windows_read + + with patch("mcp_clipboard.clipboard._run", new_callable=AsyncMock, return_value="hi"): + await _windows_read("text/html") + + # Last script in the powershell -Command argv slot + script = await _capture_last_powershell_script("text/html") + assert "[Console]::Write(" in script, ( + "text/html branch must use [Console]::Write to suppress PowerShell's " + f"default-formatter trailing CRLF. Script: {script}" + ) + # Bare `$data` at the end (the old buggy pattern) must not appear at the + # tail of the script. Match-on-end to avoid catching `$data` inside the + # GetData() capture or inside the [Console]::Write argument. + assert not script.rstrip().endswith("$data"), ( + f"text/html script still ends with bare $data: {script}" + ) + + +@pytest.mark.asyncio +async def test_windows_read_plain_uses_console_write_with_get_clipboard_raw(): + """`_windows_read` for text/plain uses `Get-Clipboard -Raw` (single string, + preserves CRLF) wrapped in `[Console]::Write` rather than a bare + `Get-Clipboard` (returns array of lines, default formatter rejoins).""" + script = await _capture_last_powershell_script("text/plain") + assert "[Console]::Write(" in script + assert "Get-Clipboard -Raw" in script + + +@pytest.mark.asyncio +async def test_windows_read_rtf_uses_console_write_to_suppress_trailing_crlf(): + """text/rtf branch ends with `[Console]::Write($data)`, not bare `$data`.""" + script = await _capture_last_powershell_script("text/rtf") + assert "[Console]::Write(" in script + assert not script.rstrip().endswith("$data") + + +@pytest.mark.asyncio +async def test_windows_read_svg_uses_console_write_to_suppress_trailing_crlf(): + """image/svg+xml branch ends with `[Console]::Write($data)`. Live testing + on a QEMU Windows guest reproduced a trailing `\\r\\n` glued onto the SVG + payload before this fix landed.""" + script = await _capture_last_powershell_script("image/svg+xml") + assert "[Console]::Write(" in script + assert not script.rstrip().endswith("$data") + + +async def _capture_last_powershell_script(mime: str) -> str: + """Helper: invoke `_windows_read(mime)` with `_run` mocked, return the + last argv element passed to powershell -Command.""" + from mcp_clipboard.clipboard import _windows_read + + with patch("mcp_clipboard.clipboard._run", new_callable=AsyncMock, return_value="x") as m: + await _windows_read(mime) + cmd = m.call_args[0][0] + return cmd[-1] + + @pytest.mark.asyncio async def test_windows_read_svg_uses_data_object_get_data(): """_windows_read for image/svg+xml calls Clipboard::GetData('image/svg+xml') From 91918160636f398da7161d25a03d479516185ff6 Mon Sep 17 00:00:00 2001 From: "cmeans-claude-dev[bot]" <272174644+cmeans-claude-dev[bot]@users.noreply.github.com> Date: Thu, 7 May 2026 19:06:17 -0500 Subject: [PATCH 6/7] test: split SVG and CRLF test blocks per QA Round 4 F2 QA reviewer flagged that the `# SVG round-trip read paths` comment block at tests/test_server.py:2873 contains 4 trailing-CRLF tests (Windows reads for html/plain/rtf/svg) alongside the 8 SVG-specific tests. The CRLF tests are not SVG-specific; a reader grepping for `trailing_crlf` would find them under a header that doesn't mention CRLF. Split into two adjacent comment blocks: - `# Windows read trailing-CRLF suppression (#138 round 4)` for the 4 CRLF tests + the `_capture_last_powershell_script` helper that they share. - `# SVG round-trip read paths (#136)` for the 8 SVG-specific tests, cross-linked to its issue. The two concerns are now independent and self-contained. No code logic change; pure organization. Test count unchanged at 622. --- tests/test_server.py | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/tests/test_server.py b/tests/test_server.py index ac0ae57..28068fe 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -2870,13 +2870,17 @@ async def test_windows_write_typed_unsupported_lists_svg(): # --------------------------------------------------------------------------- -# SVG round-trip read paths +# Windows read trailing-CRLF suppression (#138 round 4) # -# SVG is XML text but conventionally uses image/* MIME type, so it falls -# into a gap: dispatched as binary by `image/*` filters but rejected by -# raster image readers as "Unsupported image type". The fix routes SVG -# through a text-read branch on each backend and through a dedicated -# `clipboard_paste` SVG-fallback branch when no raster image is available. +# Every `_windows_read` branch was ending its PowerShell script with a bare +# expression (`$data` for HTML/RTF/SVG, `Get-Clipboard` for text/plain), +# which flows through PowerShell's default Out-Default formatter and gets +# a trailing CRLF appended. Each branch now ends with `[Console]::Write($data)` +# (or `[Console]::Write((Get-Clipboard -Raw))` for text/plain) so the value +# is written byte-for-byte with no implicit newline. Live testing on a QEMU +# Windows guest after the SVG fix landed surfaced the artifact: a `\r\n` +# was visible between `` and the closing fence in `clipboard_paste`'s +# wrapped output. # --------------------------------------------------------------------------- @@ -2945,6 +2949,17 @@ async def _capture_last_powershell_script(mime: str) -> str: return cmd[-1] +# --------------------------------------------------------------------------- +# SVG round-trip read paths (#136) +# +# SVG is XML text but conventionally uses image/* MIME type, so it falls +# into a gap: dispatched as binary by `image/*` filters but rejected by +# raster image readers as "Unsupported image type". The fix routes SVG +# through a text-read branch on each backend and through a dedicated +# `clipboard_paste` SVG-fallback branch when no raster image is available. +# --------------------------------------------------------------------------- + + @pytest.mark.asyncio async def test_windows_read_svg_uses_data_object_get_data(): """_windows_read for image/svg+xml calls Clipboard::GetData('image/svg+xml') From 5deed85df3b7d240b07d40771afef99f3f76166c Mon Sep 17 00:00:00 2001 From: "cmeans-claude-dev[bot]" <272174644+cmeans-claude-dev[bot]@users.noreply.github.com> Date: Thu, 7 May 2026 20:07:34 -0500 Subject: [PATCH 7/7] revert: undo R4 CRLF-suppression cleanup (regressed SVG rendering in CD on Windows) Reverts two commits: - 9191816 ("test: split SVG and CRLF test blocks per QA Round 4 F2") - 47703a1 ("fix(windows): suppress PowerShell default-formatter trailing CRLF on clipboard reads (folded into #138)") Reason: live testing on Claude Desktop on Windows after R4 confirmed the user-visible SVG render broke at R4. The pre-R4 SVG round-trip (R3 head 4277e5a) rendered correctly in CD on Windows. The CRLF cleanup was an unrequested presentational change motivated by a contract-test concern (trailing \r\n in JSON-RPC byte output looking "wrong") and was not justified against any user-visible requirement; the trailing CRLF turned out to be load-bearing for the host's render path in some way that was not investigated before the change landed. Restoring the four _windows_read PowerShell branches to their pre-R4 form (bare expressions with original null-guards), removing the four CRLF-specific tests + the _capture_last_powershell_script helper, and dropping the CHANGELOG entry that described the CRLF cleanup. Branch returns to functional parity with R3 head 4277e5a on the clipboard side. PR #138 scope reverts to "fix: SVG clipboard round-trip on Windows + macOS" only; the title and body need to be updated in a follow-up step. Requires re-verification on the QEMU Windows guest before this can be called fixed again. The CI byte-level tests are not sufficient evidence for this PR. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 15 ------- src/mcp_clipboard/clipboard.py | 33 ++++---------- tests/test_server.py | 82 +--------------------------------- 3 files changed, 10 insertions(+), 120 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba04bf4..2482b0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,21 +25,6 @@ All notable changes to this project will be documented here. source" clipboard still returns the viewable PNG as before. The Linux backends (Wayland/X11) were already correct via passthrough on `wl-paste --type` / `xclip -target`. -- Trailing CRLF on Windows clipboard reads. Live testing on a QEMU - Windows guest after the SVG fix landed showed a `\r\n` sequence - glued onto the read-back payload (visible as a blank line between - the closing `` and the closing fence in `clipboard_paste`'s - output). Root cause: every `_windows_read` branch was ending its - PowerShell script with a bare expression (`$data` for HTML/RTF/SVG, - `Get-Clipboard` for text/plain), which flows through PowerShell's - default Out-Default formatter and gets a trailing CRLF appended. - All four branches now end with `[Console]::Write($data)` (or - `[Console]::Write((Get-Clipboard -Raw))` for the text/plain path, - using `-Raw` so multi-line clipboard content is preserved as a - single string with original line endings rather than reassembled - by the formatter). `[Console]::Write` writes the value byte-for-byte - with no implicit newline; passing `$null` is a no-op so the prior - `if ($data -eq $null) { return }` guards are also gone. ## [2.6.0] - 2026-05-07 diff --git a/src/mcp_clipboard/clipboard.py b/src/mcp_clipboard/clipboard.py index 9b77b45..0dacba3 100644 --- a/src/mcp_clipboard/clipboard.py +++ b/src/mcp_clipboard/clipboard.py @@ -418,44 +418,29 @@ async def _macos_list_formats(selection: str = "clipboard") -> list[str]: async def _windows_read(mime_type: str, selection: str = "clipboard") -> str: - # Every branch below ends in `[Console]::Write($data)` rather than letting - # the captured value flow to PowerShell's default Out-Default formatter. - # The default formatter appends a trailing CRLF to whatever it prints, so - # bare `$data` at the end of a script returns content + `\r\n` to Python's - # _run() decoder. `[Console]::Write` writes the string as-is with no - # newline appended, preserving byte-exact round-trip for clipboard - # content that doesn't have its own trailing newline. `[Console]::Write` - # also tolerates `$null` (no-op) so the prior `if ($data -eq $null)` - # guards are no longer necessary. _reject_non_clipboard_selection(selection, "Windows") if mime_type == "text/html": + # PowerShell: Get HTML format from clipboard script = ( - "Add-Type -AssemblyName System.Windows.Forms; " - "$data = [System.Windows.Forms.Clipboard]::GetData(" - "[System.Windows.Forms.DataFormats]::Html); " - "[Console]::Write($data)" + "[System.Windows.Forms.Clipboard]::GetData([System.Windows.Forms.DataFormats]::Html)" ) return await _run( [ "powershell", "-NoProfile", "-Command", - script, + f"Add-Type -AssemblyName System.Windows.Forms; {script}", ], allow_empty_exit=False, ) if mime_type == "text/plain": - # `Get-Clipboard -Raw` returns the entire clipboard as a single string - # preserving original line endings; without -Raw it returns an array - # of lines and the default formatter rejoins with \n (losing CRLF info - # and adding a trailing newline). return await _run( [ "powershell", "-NoProfile", "-Command", - "[Console]::Write((Get-Clipboard -Raw))", + "Get-Clipboard", ], allow_empty_exit=False, ) @@ -465,7 +450,7 @@ async def _windows_read(mime_type: str, selection: str = "clipboard") -> str: "Add-Type -AssemblyName System.Windows.Forms; " "$data = [System.Windows.Forms.Clipboard]::GetData(" "[System.Windows.Forms.DataFormats]::Rtf); " - "[Console]::Write($data)" + "if ($data -eq $null) { return }; $data" ) return await _run( [ @@ -480,14 +465,14 @@ async def _windows_read(mime_type: str, selection: str = "clipboard") -> str: if mime_type == "image/svg+xml": # SVG is written via _windows_write_typed as a custom format string # 'image/svg+xml' on the DataObject. Reading it back uses the same - # format string. OutputEncoding=UTF8 prevents PowerShell's default - # OEM OutputEncoding from mangling the UTF-8 SVG markup on the way - # back to Python's _run() decoder. + # format string. Output encoding matters here too: PowerShell's + # default OutputEncoding can mangle the UTF-8 SVG markup on the way + # back to Python's _run() decoder. Force UTF-8 on stdout. script = ( "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; " "Add-Type -AssemblyName System.Windows.Forms; " "$data = [System.Windows.Forms.Clipboard]::GetData('image/svg+xml'); " - "[Console]::Write($data)" + "if ($data -eq $null) { return }; $data" ) return await _run( [ diff --git a/tests/test_server.py b/tests/test_server.py index 28068fe..8cb47e1 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -2870,87 +2870,7 @@ async def test_windows_write_typed_unsupported_lists_svg(): # --------------------------------------------------------------------------- -# Windows read trailing-CRLF suppression (#138 round 4) -# -# Every `_windows_read` branch was ending its PowerShell script with a bare -# expression (`$data` for HTML/RTF/SVG, `Get-Clipboard` for text/plain), -# which flows through PowerShell's default Out-Default formatter and gets -# a trailing CRLF appended. Each branch now ends with `[Console]::Write($data)` -# (or `[Console]::Write((Get-Clipboard -Raw))` for text/plain) so the value -# is written byte-for-byte with no implicit newline. Live testing on a QEMU -# Windows guest after the SVG fix landed surfaced the artifact: a `\r\n` -# was visible between `` and the closing fence in `clipboard_paste`'s -# wrapped output. -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_windows_read_html_uses_console_write_to_suppress_trailing_crlf(): - """`_windows_read` for text/html ends with `[Console]::Write($data)` rather - than a bare `$data` expression, so PowerShell's default Out-Default - formatter doesn't append a trailing CRLF to the returned content. Same - pattern is required across every Windows read branch (#138 round 3 follow-up - after live-VM testing showed `\\r\\n` glued onto the SVG payload).""" - from mcp_clipboard.clipboard import _windows_read - - with patch("mcp_clipboard.clipboard._run", new_callable=AsyncMock, return_value="hi"): - await _windows_read("text/html") - - # Last script in the powershell -Command argv slot - script = await _capture_last_powershell_script("text/html") - assert "[Console]::Write(" in script, ( - "text/html branch must use [Console]::Write to suppress PowerShell's " - f"default-formatter trailing CRLF. Script: {script}" - ) - # Bare `$data` at the end (the old buggy pattern) must not appear at the - # tail of the script. Match-on-end to avoid catching `$data` inside the - # GetData() capture or inside the [Console]::Write argument. - assert not script.rstrip().endswith("$data"), ( - f"text/html script still ends with bare $data: {script}" - ) - - -@pytest.mark.asyncio -async def test_windows_read_plain_uses_console_write_with_get_clipboard_raw(): - """`_windows_read` for text/plain uses `Get-Clipboard -Raw` (single string, - preserves CRLF) wrapped in `[Console]::Write` rather than a bare - `Get-Clipboard` (returns array of lines, default formatter rejoins).""" - script = await _capture_last_powershell_script("text/plain") - assert "[Console]::Write(" in script - assert "Get-Clipboard -Raw" in script - - -@pytest.mark.asyncio -async def test_windows_read_rtf_uses_console_write_to_suppress_trailing_crlf(): - """text/rtf branch ends with `[Console]::Write($data)`, not bare `$data`.""" - script = await _capture_last_powershell_script("text/rtf") - assert "[Console]::Write(" in script - assert not script.rstrip().endswith("$data") - - -@pytest.mark.asyncio -async def test_windows_read_svg_uses_console_write_to_suppress_trailing_crlf(): - """image/svg+xml branch ends with `[Console]::Write($data)`. Live testing - on a QEMU Windows guest reproduced a trailing `\\r\\n` glued onto the SVG - payload before this fix landed.""" - script = await _capture_last_powershell_script("image/svg+xml") - assert "[Console]::Write(" in script - assert not script.rstrip().endswith("$data") - - -async def _capture_last_powershell_script(mime: str) -> str: - """Helper: invoke `_windows_read(mime)` with `_run` mocked, return the - last argv element passed to powershell -Command.""" - from mcp_clipboard.clipboard import _windows_read - - with patch("mcp_clipboard.clipboard._run", new_callable=AsyncMock, return_value="x") as m: - await _windows_read(mime) - cmd = m.call_args[0][0] - return cmd[-1] - - -# --------------------------------------------------------------------------- -# SVG round-trip read paths (#136) +# SVG round-trip read paths # # SVG is XML text but conventionally uses image/* MIME type, so it falls # into a gap: dispatched as binary by `image/*` filters but rejected by