Skip to content
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 38 additions & 0 deletions src/mcp_clipboard/clipboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ""

Expand All @@ -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",
Expand Down Expand Up @@ -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 ""

Expand Down
97 changes: 64 additions & 33 deletions src/mcp_clipboard/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
177 changes: 177 additions & 0 deletions tests/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
'<svg xmlns="http://www.w3.org/2000/svg">'
+ ("<rect/>" * (_MAX_CONTENT_CHARS // 7))
+ "</svg>"
)
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)
#
Expand Down