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..8cb47e1 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -2869,6 +2869,183 @@ 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.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_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 + '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.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) #