diff --git a/CHANGELOG.md b/CHANGELOG.md index 461b09a..d496f4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,21 @@ All notable changes to this project will be documented here. ## [Unreleased] ### Added +- X11 PRIMARY / Wayland primary-selection support on the read tools: + `clipboard_paste`, `clipboard_read_raw`, and `clipboard_list_formats` + now accept an optional `selection` argument (`"clipboard"` default, + `"primary"` for the middle-click / select-text-to-paste buffer). + Wayland uses `wl-paste --primary`; X11 uses `xclip -selection primary`. + macOS and Windows have no PRIMARY analog and return a clear + `selection={selection!r}` error if anything other than `"clipboard"` + is passed. Power-user workflow: select text in a terminal or browser + without Ctrl-C, then `clipboard_paste(selection="primary")` returns + it. Public APIs `read_clipboard`, `list_clipboard_formats`, and + `read_clipboard_image` gained the same `selection` parameter. + Linux validated under Xvfb in CI (PRIMARY round-trip plus list + formats); macOS and Windows reject paths unit-tested. Write-side + PRIMARY support deferred — issue #110 noted lower priority for it. + (#122) - closes #110. - New `clipboard_copy_markdown(text)` tool: render a markdown source string to HTML (via `markdown-it-py` with raw-HTML pass-through disabled, so the rendered output is safe by construction) and write both `text/html` and diff --git a/CLAUDE.md b/CLAUDE.md index 2644ab4..5100328 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,12 +47,12 @@ git tag test-v0.1.x && git push origin test-v0.1.x # triggers test-publish.yml Three-layer design with clean separation: - **server.py** — MCP server (`FastMCP`, name `mcp_clipboard`) exposing 6 tools: - - `clipboard_paste(output_format)` — Primary tool. Handles any clipboard content: tables → markdown/json/csv; non-tabular → smart formatting (JSON, code, URL, text). Images are returned as base64-encoded image content. Audio/video are detected and reported. + - `clipboard_paste(output_format, include_schema, selection)` — Primary tool. Handles any clipboard content: tables → markdown/json/csv; non-tabular → smart formatting (JSON, code, URL, text). Images are returned as base64-encoded image content. Audio/video are detected and reported. `selection` defaults to `"clipboard"`; pass `"primary"` on X11/Wayland to read the middle-click/select-text-to-paste buffer. - `clipboard_copy(content)` — Writes text to the system clipboard. - `clipboard_copy_markdown(text)` — Renders markdown to HTML and places both formats on the clipboard so paste targets pick the right one. macOS/Windows write both atomically via multi-format clipboard APIs; Wayland/X11 are single-MIME and write only `text/html`. - `clipboard_copy_image(image_data, mime_type)` — Writes a PNG or JPEG image to the system clipboard from base64-encoded bytes. Pass-through, no re-encoding. Magic bytes are validated against the declared MIME. - - `clipboard_read_raw(mime_type)` — Returns raw clipboard content for a given MIME type (truncated at 50KB). Rejects binary MIME types. - - `clipboard_list_formats()` — Lists available MIME types on clipboard. + - `clipboard_read_raw(mime_type, selection)` — Returns raw clipboard content for a given MIME type (truncated at 50KB). Rejects binary MIME types. Accepts `selection="primary"` on X11/Wayland. + - `clipboard_list_formats(selection)` — Lists available MIME types on clipboard. Accepts `selection="primary"` on X11/Wayland. - **clipboard.py** — Platform-agnostic clipboard abstraction. Auto-detects backend (Wayland `wl-paste`/`wl-copy`, X11 `xclip`, macOS `osascript`/`pbpaste`/`pbcopy`, Windows PowerShell). All operations are async with 5-second timeout. Exit code 1 means "format not available" (not an error). macOS UTI types and Windows format names are mapped to MIME types in `list_formats`. Supports text read/write and binary image reads. diff --git a/README.md b/README.md index 7b6ceaf..6e271df 100644 --- a/README.md +++ b/README.md @@ -66,12 +66,12 @@ Claude Code will then copy every command it suggests without you having to ask. | Tool | Description | | --- | --- | -| `clipboard_paste` | **Primary tool.** Read any clipboard content: tables, text, code, JSON, URLs, images. Tables are formatted as Markdown/JSON/CSV; pass `include_schema=true` to append inferred column types. Images are returned as image content the model can see. | +| `clipboard_paste` | **Primary tool.** Read any clipboard content: tables, text, code, JSON, URLs, images. Tables are formatted as Markdown/JSON/CSV; pass `include_schema=true` to append inferred column types. Images are returned as image content the model can see. Optional `selection="primary"` reads the X11/Wayland PRIMARY selection (middle-click / select-text-to-paste buffer) instead of the default Ctrl-C clipboard. | | `clipboard_copy` | Write text content to the system clipboard. Accepts an optional `mime_type` parameter (`text/plain` by default; also `text/html`, `text/rtf`, `image/svg+xml`, or any `text/*` on Wayland/X11). | | `clipboard_copy_markdown` | Render markdown to HTML and place both formats on the clipboard so paste targets pick the right one — Slack/Gmail/Notion/Discord get rich text; vim/terminal get the source. macOS/Windows write both atomically; Wayland/X11 are single-MIME and write only `text/html`. | | `clipboard_copy_image` | Write a PNG or JPEG image to the system clipboard from base64-encoded bytes. Pass-through with no re-encoding; magic bytes are validated against the declared MIME. Use `clipboard_copy` for text. | -| `clipboard_list_formats` | List what MIME types are currently on the clipboard. | -| `clipboard_read_raw` | Return raw clipboard content for a given MIME type (diagnostic). Any non-binary type passes through; only `image/*`, `audio/*`, `video/*`, and `application/octet-stream` are rejected. Use `clipboard_paste` for images. | +| `clipboard_list_formats` | List what MIME types are currently on the clipboard. Accepts `selection="primary"` for the X11/Wayland PRIMARY selection. | +| `clipboard_read_raw` | Return raw clipboard content for a given MIME type (diagnostic). Any non-binary type passes through; only `image/*`, `audio/*`, `video/*`, and `application/octet-stream` are rejected. Use `clipboard_paste` for images. Accepts `selection="primary"` for the X11/Wayland PRIMARY selection. | ## Setup diff --git a/src/mcp_clipboard/clipboard.py b/src/mcp_clipboard/clipboard.py index b2be70e..6f2aabd 100644 --- a/src/mcp_clipboard/clipboard.py +++ b/src/mcp_clipboard/clipboard.py @@ -277,27 +277,62 @@ def _detect_backend() -> str: # Backend implementations # --------------------------------------------------------------------------- +# Selections supported by the X11 + Wayland read paths. "clipboard" is the +# default Ctrl-C buffer; "primary" is the X11 PRIMARY selection (middle-click +# / select-text-to-paste) which Wayland mirrors via wl-paste --primary on +# most compositors. macOS and Windows have no analog and reject "primary". +_VALID_SELECTIONS: frozenset[str] = frozenset({"clipboard", "primary"}) -async def _wayland_read(mime_type: str) -> str: - return await _run(["wl-paste", "--type", mime_type], env=_wayland_env()) +def _wayland_primary_args(selection: str) -> list[str]: + """Return the wl-paste/wl-copy --primary flag (or empty list).""" + return ["--primary"] if selection == "primary" else [] -async def _wayland_list_formats() -> list[str]: - raw = await _run(["wl-paste", "--list-types"], env=_wayland_env()) + +def _validate_selection(selection: str) -> None: + if selection not in _VALID_SELECTIONS: + raise ClipboardError( + f"Invalid selection: {selection!r}. Supported: {', '.join(sorted(_VALID_SELECTIONS))}" + ) + + +async def _wayland_read(mime_type: str, selection: str = "clipboard") -> str: + _validate_selection(selection) + args = ["wl-paste", "--type", mime_type, *_wayland_primary_args(selection)] + return await _run(args, env=_wayland_env()) + + +async def _wayland_list_formats(selection: str = "clipboard") -> list[str]: + _validate_selection(selection) + args = ["wl-paste", "--list-types", *_wayland_primary_args(selection)] + raw = await _run(args, env=_wayland_env()) return [line.strip() for line in raw.splitlines() if line.strip()] -async def _x11_read(mime_type: str) -> str: - # xclip uses -target for MIME, -selection clipboard for the main clipboard - return await _run(["xclip", "-selection", "clipboard", "-target", mime_type, "-o"]) +async def _x11_read(mime_type: str, selection: str = "clipboard") -> str: + _validate_selection(selection) + return await _run(["xclip", "-selection", selection, "-target", mime_type, "-o"]) -async def _x11_list_formats() -> list[str]: - raw = await _run(["xclip", "-selection", "clipboard", "-target", "TARGETS", "-o"]) +async def _x11_list_formats(selection: str = "clipboard") -> list[str]: + _validate_selection(selection) + raw = await _run(["xclip", "-selection", selection, "-target", "TARGETS", "-o"]) return [line.strip() for line in raw.splitlines() if line.strip()] -async def _macos_read(mime_type: str) -> str: +def _reject_non_clipboard_selection(selection: str, platform_name: str) -> None: + """macOS and Windows have no PRIMARY-selection analog. Reject explicitly + so the host model gets a clean error rather than silently falling back + to the system clipboard (which would mask intent mismatches).""" + if selection != "clipboard": + raise ClipboardError( + f"{platform_name} does not support selection={selection!r}; " + f"only the 'clipboard' selection exists on this platform." + ) + + +async def _macos_read(mime_type: str, selection: str = "clipboard") -> str: + _reject_non_clipboard_selection(selection, "macOS") if mime_type == "text/html": # osascript to get HTML from clipboard script = ( @@ -342,7 +377,8 @@ async def _macos_read(mime_type: str) -> str: } -async def _macos_list_formats() -> list[str]: +async def _macos_list_formats(selection: str = "clipboard") -> list[str]: + _reject_non_clipboard_selection(selection, "macOS") script = ( 'use framework "AppKit"\n' "set pb to current application's NSPasteboard's generalPasteboard()\n" @@ -365,7 +401,8 @@ async def _macos_list_formats() -> list[str]: return result -async def _windows_read(mime_type: str) -> str: +async def _windows_read(mime_type: str, selection: str = "clipboard") -> str: + _reject_non_clipboard_selection(selection, "Windows") if mime_type == "text/html": # PowerShell: Get HTML format from clipboard script = ( @@ -423,7 +460,8 @@ async def _windows_read(mime_type: str) -> str: } -async def _windows_list_formats() -> list[str]: +async def _windows_list_formats(selection: str = "clipboard") -> list[str]: + _reject_non_clipboard_selection(selection, "Windows") script = ( "Add-Type -AssemblyName System.Windows.Forms; " "[System.Windows.Forms.Clipboard]::GetDataObject().GetFormats()" @@ -447,15 +485,19 @@ async def _windows_list_formats() -> list[str]: # --------------------------------------------------------------------------- -async def _wayland_read_image(mime_type: str) -> bytes: - return await _run_binary(["wl-paste", "--type", mime_type], env=_wayland_env()) +async def _wayland_read_image(mime_type: str, selection: str = "clipboard") -> bytes: + _validate_selection(selection) + args = ["wl-paste", "--type", mime_type, *_wayland_primary_args(selection)] + return await _run_binary(args, env=_wayland_env()) -async def _x11_read_image(mime_type: str) -> bytes: - return await _run_binary(["xclip", "-selection", "clipboard", "-target", mime_type, "-o"]) +async def _x11_read_image(mime_type: str, selection: str = "clipboard") -> bytes: + _validate_selection(selection) + return await _run_binary(["xclip", "-selection", selection, "-target", mime_type, "-o"]) -async def _macos_read_image(mime_type: str) -> bytes: +async def _macos_read_image(mime_type: str, selection: str = "clipboard") -> bytes: + _reject_non_clipboard_selection(selection, "macOS") # Map MIME to UTI for NSPasteboard lookup -- reject unknown types # to prevent AppleScript injection via crafted MIME strings (#24) mime_to_uti = {v: k for k, v in _UTI_TO_MIME.items() if v.startswith("image/")} @@ -487,7 +529,8 @@ async def _macos_read_image(mime_type: str) -> bytes: } -async def _windows_read_image(mime_type: str) -> bytes: +async def _windows_read_image(mime_type: str, selection: str = "clipboard") -> bytes: + _reject_non_clipboard_selection(selection, "Windows") # Map MIME to .NET ImageFormat -- reject unknown types (#34) dotnet_format = _WINDOWS_IMAGE_FORMATS.get(mime_type) if dotnet_format is None: @@ -1043,7 +1086,7 @@ def reset_backend_cache() -> None: } -async def read_clipboard(mime_type: str = "text/plain") -> str: +async def read_clipboard(mime_type: str = "text/plain", selection: str = "clipboard") -> str: """Read the clipboard content in the specified MIME type. Returns an empty string if the requested format is not available. @@ -1052,50 +1095,62 @@ async def read_clipboard(mime_type: str = "text/plain") -> str: ``text/plain;charset=utf-8``). If the exact requested type is not found, this function falls back to listing available formats and retrying with a matching suffixed variant. + + ``selection`` selects which X11/Wayland buffer to read: ``"clipboard"`` + (default, the Ctrl-C buffer) or ``"primary"`` (X11 PRIMARY / middle- + click selection; Wayland's analogous primary selection on most + compositors). macOS and Windows have no PRIMARY analog and raise + :exc:`ClipboardError` for any non-default selection. (#110) """ backend = _get_backend() - result = await _READERS[backend](mime_type) + result = await _READERS[backend](mime_type, selection) # Wayland / X11 pass the MIME type verbatim to wl-paste / xclip which # may do strict matching. Resolve via format listing when needed. if not result and backend in ("wayland", "x11"): base = base_mime_type(mime_type) - formats = await _FORMAT_LISTERS[backend]() + formats = await _FORMAT_LISTERS[backend](selection) for fmt in formats: if fmt != mime_type and base_mime_type(fmt) == base: - result = await _READERS[backend](fmt) + result = await _READERS[backend](fmt, selection) if result: break return result -async def list_clipboard_formats() -> list[str]: - """Return the list of MIME/format types currently available on the clipboard.""" +async def list_clipboard_formats(selection: str = "clipboard") -> list[str]: + """Return the list of MIME/format types currently available on the clipboard. + + ``selection`` is ``"clipboard"`` (default) or ``"primary"`` — see + :func:`read_clipboard` for the per-platform contract. (#110) + """ backend = _get_backend() - return await _FORMAT_LISTERS[backend]() + return await _FORMAT_LISTERS[backend](selection) -async def read_clipboard_image(mime_type: str = "image/png") -> bytes: +async def read_clipboard_image(mime_type: str = "image/png", selection: str = "clipboard") -> bytes: """Read binary image data from the clipboard. Returns raw bytes of the image, or empty bytes if not available. Like :func:`read_clipboard`, falls back to a matching suffixed MIME - type when the exact requested type is not available. + type when the exact requested type is not available, and accepts a + ``selection`` parameter (``"clipboard"`` default; ``"primary"`` on + Wayland/X11). (#110) Raises :exc:`ClipboardSizeError` when the image exceeds ``MCP_CLIPBOARD_MAX_IMAGE_BYTES`` (default 10 MB). """ backend = _get_backend() - result = await _IMAGE_READERS[backend](mime_type) + result = await _IMAGE_READERS[backend](mime_type, selection) if not result and backend in ("wayland", "x11"): base = base_mime_type(mime_type) - formats = await _FORMAT_LISTERS[backend]() + formats = await _FORMAT_LISTERS[backend](selection) for fmt in formats: if fmt != mime_type and base_mime_type(fmt) == base: - result = await _IMAGE_READERS[backend](fmt) + result = await _IMAGE_READERS[backend](fmt, selection) if result: break diff --git a/src/mcp_clipboard/instructions/clipboard_list_formats.md b/src/mcp_clipboard/instructions/clipboard_list_formats.md index 97acf3e..04e728e 100644 --- a/src/mcp_clipboard/instructions/clipboard_list_formats.md +++ b/src/mcp_clipboard/instructions/clipboard_list_formats.md @@ -4,5 +4,11 @@ tool — use clipboard_paste to actually read and return clipboard content. Lists what formats are present. For spreadsheet data, you want to see "text/html" (best) or "text/plain" (fallback with tab-separated values). +Args: + selection: Which buffer to list. Defaults to "clipboard". Pass + "primary" to list formats on the X11/Wayland PRIMARY selection. + macOS and Windows have no PRIMARY analog and will return an + error if "primary" is passed. + Returns: A list of available clipboard formats. diff --git a/src/mcp_clipboard/instructions/clipboard_paste.md b/src/mcp_clipboard/instructions/clipboard_paste.md index 85bd2b3..467899c 100644 --- a/src/mcp_clipboard/instructions/clipboard_paste.md +++ b/src/mcp_clipboard/instructions/clipboard_paste.md @@ -37,6 +37,11 @@ Args: include_schema: When True and the clipboard contains a table, append a column-type schema table after the data. Inferred types: integer, float, currency, percentage, date, boolean, text. Defaults to False. + selection: Which buffer to read. Defaults to "clipboard" (the standard + Ctrl-C / Cmd-C clipboard). Pass "primary" to read the X11 PRIMARY + selection (middle-click / select-text-to-paste buffer) or the + analogous Wayland primary selection. macOS and Windows have no + PRIMARY analog and will return an error if "primary" is passed. Returns: The clipboard content, formatted appropriately for the content type. diff --git a/src/mcp_clipboard/instructions/clipboard_read_raw.md b/src/mcp_clipboard/instructions/clipboard_read_raw.md index 9eb2a6f..b2f51e5 100644 --- a/src/mcp_clipboard/instructions/clipboard_read_raw.md +++ b/src/mcp_clipboard/instructions/clipboard_read_raw.md @@ -17,6 +17,10 @@ Args: mime_type: The MIME type to read from the clipboard. Common values: "text/plain", "text/html", "image/svg+xml", "application/json" + selection: Which buffer to read. Defaults to "clipboard". Pass + "primary" to read the X11/Wayland PRIMARY selection (middle-click + buffer). macOS and Windows have no PRIMARY analog and will return + an error if "primary" is passed. Returns: The raw clipboard content in the requested format, or an error message. diff --git a/src/mcp_clipboard/server.py b/src/mcp_clipboard/server.py index 2e8b873..daf23f6 100644 --- a/src/mcp_clipboard/server.py +++ b/src/mcp_clipboard/server.py @@ -120,6 +120,22 @@ def _load_icons() -> list[Icon]: # MIME type validation: type and subtype must start with a letter. _MIME_RE = re.compile(r"^[a-zA-Z][\w.+\-]*/[a-zA-Z][\w.+\-]*(;\s*[\w.+\-]+=[\w.+\-]+)*$") +# Selections accepted by the read tools. "primary" routes to X11 PRIMARY / +# Wayland primary selection; macOS and Windows reject it explicitly. (#110) +_VALID_SELECTIONS_TOOL: frozenset[str] = frozenset({"clipboard", "primary"}) + + +def _validate_tool_selection(selection: str) -> str | None: + """Validate a `selection` argument from a tool call. Returns a friendly + error message if invalid, or None if OK. The string is what the host + model sees, so phrase it for the model.""" + if selection not in _VALID_SELECTIONS_TOOL: + return ( + f"Invalid selection: {selection!r}. " + f"Supported: {', '.join(sorted(_VALID_SELECTIONS_TOOL))}." + ) + return None + def _safe_code_fence(text: str) -> str: """Return a backtick fence long enough to wrap ``text`` without escape. @@ -140,7 +156,9 @@ def _safe_code_fence(text: str) -> str: return "`" * max(3, longest + 1) -async def _read_clipboard_content() -> tuple[list[list[str]], str, str]: +async def _read_clipboard_content( + selection: str = "clipboard", +) -> tuple[list[list[str]], str, str]: """Read clipboard and attempt to extract tabular data. Returns (rows, html, text) where rows may be empty if no table found. @@ -152,7 +170,7 @@ async def _read_clipboard_content() -> tuple[list[list[str]], str, str]: # Strategy 1: Try HTML clipboard (most reliable for spreadsheets) try: - html = await read_clipboard("text/html") + html = await read_clipboard("text/html", selection) if html: rows = parse_html_table(html) except ClipboardError as e: @@ -161,7 +179,7 @@ async def _read_clipboard_content() -> tuple[list[list[str]], str, str]: # Strategy 2: Fall back to TSV in plain text if not rows: try: - text = await read_clipboard("text/plain") + text = await read_clipboard("text/plain", selection) if text: rows = parse_tsv(text) except ClipboardError as e: @@ -226,6 +244,7 @@ def _format_non_tabular(text: str) -> str: async def clipboard_paste( output_format: str = "markdown", include_schema: bool = False, + selection: str = "clipboard", ): # NOTE: No return type annotation here by design. The true type is # `str | Image`, but annotating it that way causes FastMCP to call @@ -239,14 +258,19 @@ async def clipboard_paste( f"Unknown output_format: {output_format!r}. " f"Valid options: {', '.join(sorted(_VALID_FORMATS))}" ) + selection = selection.strip().lower() + selection_err = _validate_tool_selection(selection) + if selection_err is not None: + return selection_err logger.debug( - "clipboard_paste called: output_format=%r include_schema=%r", + "clipboard_paste called: output_format=%r include_schema=%r selection=%r", output_format, include_schema, + selection, ) - rows, html, text = await _read_clipboard_content() + rows, html, text = await _read_clipboard_content(selection) # If we found tabular data, format it if rows: @@ -280,7 +304,7 @@ async def clipboard_paste( # Strategy 3: RTF fallback — try text/rtf when HTML and plain text are empty if not content.strip(): try: - rtf = await read_clipboard("text/rtf") + rtf = await read_clipboard("text/rtf", selection) if rtf.strip(): truncated = len(rtf) > _MAX_CONTENT_CHARS display = rtf[:_MAX_CONTENT_CHARS] @@ -295,7 +319,7 @@ async def clipboard_paste( # If no text content at all, check whether the clipboard holds binary data if not content.strip(): try: - formats = await list_clipboard_formats() + 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) @@ -304,7 +328,7 @@ async def clipboard_paste( image_formats[0], ) try: - data = await read_clipboard_image(mime) + 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: @@ -346,6 +370,7 @@ async def clipboard_paste( ) async def clipboard_read_raw( mime_type: str = "text/plain", + selection: str = "clipboard", ) -> str: base_type = base_mime_type(mime_type) if not _MIME_RE.match(base_type): @@ -361,9 +386,13 @@ async def clipboard_read_raw( f"This tool only supports text-based formats (e.g. text/plain, text/html, " f"image/svg+xml, application/json)." ) + selection = selection.strip().lower() + selection_err = _validate_tool_selection(selection) + if selection_err is not None: + return selection_err try: - content = await read_clipboard(mime_type) + content = await read_clipboard(mime_type, selection) except ClipboardError as e: return f"Error reading clipboard: {e}" @@ -391,9 +420,13 @@ async def clipboard_read_raw( "openWorldHint": False, }, ) -async def clipboard_list_formats() -> str: +async def clipboard_list_formats(selection: str = "clipboard") -> str: + selection = selection.strip().lower() + selection_err = _validate_tool_selection(selection) + if selection_err is not None: + return selection_err try: - formats = await list_clipboard_formats() + formats = await list_clipboard_formats(selection) except ClipboardError as e: return f"Error listing clipboard formats: {e}" diff --git a/tests/test_integration_x11.py b/tests/test_integration_x11.py index 6a3f8c1..4dbe2fd 100644 --- a/tests/test_integration_x11.py +++ b/tests/test_integration_x11.py @@ -186,6 +186,49 @@ async def test_x11_list_formats_after_svg_write(): assert any(f == "image/svg+xml" for f in formats), f"image/svg+xml not in {formats}" +@pytest.mark.asyncio +async def test_x11_primary_selection_round_trip(): + """Write to X11 PRIMARY directly via xclip, read it back via + read_clipboard(selection='primary'). Verifies #110: the PRIMARY buffer + is reachable as a separate read source.""" + payload = "selected text in PRIMARY" + proc = await asyncio.create_subprocess_exec( + "xclip", + "-selection", + "primary", + stdin=asyncio.subprocess.PIPE, + ) + await proc.communicate(input=payload.encode()) + assert proc.returncode == 0 + + primary = await read_clipboard("text/plain", selection="primary") + assert primary.strip() == payload + + # The default CLIPBOARD selection is independent — must not return PRIMARY's + # contents just because we wrote to PRIMARY. + clipboard = await read_clipboard("text/plain", selection="clipboard") + assert clipboard.strip() != payload + + +@pytest.mark.asyncio +async def test_x11_primary_list_formats(): + """list_clipboard_formats reports targets advertised on PRIMARY only.""" + payload = "advertise me on primary" + proc = await asyncio.create_subprocess_exec( + "xclip", + "-selection", + "primary", + stdin=asyncio.subprocess.PIPE, + ) + await proc.communicate(input=payload.encode()) + assert proc.returncode == 0 + + formats = await list_clipboard_formats(selection="primary") + assert any("STRING" in f or "text" in f for f in formats), ( + f"primary text targets missing from {formats}" + ) + + @pytest.mark.asyncio async def test_x11_multi_format_write_picks_html(): """write_clipboard_multi_format on X11 picks the highest-preference MIME diff --git a/tests/test_server.py b/tests/test_server.py index cb9c12e..69b0433 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -69,7 +69,7 @@ def _mock_read(html: str = "", text: str = ""): """Create a mock for read_clipboard that returns html or text by mime type.""" - async def _read(mime_type: str = "text/plain") -> str: + async def _read(mime_type: str = "text/plain", selection: str = "clipboard") -> str: if mime_type == "text/html": return html return text @@ -80,7 +80,7 @@ async def _read(mime_type: str = "text/plain") -> str: def _mock_read_error(msg: str = "Command not found: wl-paste"): """Create a mock for read_clipboard that raises ClipboardError.""" - async def _read(mime_type: str = "text/plain") -> str: + async def _read(mime_type: str = "text/plain", selection: str = "clipboard") -> str: raise ClipboardError(msg) return _read @@ -331,7 +331,7 @@ async def test_paste_tsv_fallback(): async def test_paste_tsv_when_html_errors(): """clipboard_paste falls back to TSV when HTML read raises an error.""" - async def _mixed_read(mime_type: str = "text/plain") -> str: + async def _mixed_read(mime_type: str = "text/plain", selection: str = "clipboard") -> str: if mime_type == "text/html": raise ClipboardError("No HTML available") return SAMPLE_TSV @@ -1215,7 +1215,7 @@ async def test_read_clipboard_dispatches_to_wayland(): result = await read_clipboard("text/plain") assert result == "hello" - mock_reader.assert_called_once_with("text/plain") + mock_reader.assert_called_once_with("text/plain", "clipboard") @pytest.mark.asyncio @@ -1227,7 +1227,7 @@ async def test_read_clipboard_dispatches_to_x11(): result = await read_clipboard("text/plain") assert result == "hello" - mock_reader.assert_called_once_with("text/plain") + mock_reader.assert_called_once_with("text/plain", "clipboard") @pytest.mark.asyncio @@ -1239,7 +1239,7 @@ async def test_read_clipboard_dispatches_to_macos(): result = await read_clipboard("text/plain") assert result == "hello" - mock_reader.assert_called_once_with("text/plain") + mock_reader.assert_called_once_with("text/plain", "clipboard") @pytest.mark.asyncio @@ -1251,7 +1251,7 @@ async def test_read_clipboard_dispatches_to_windows(): result = await read_clipboard("text/plain") assert result == "hello" - mock_reader.assert_called_once_with("text/plain") + mock_reader.assert_called_once_with("text/plain", "clipboard") @pytest.mark.asyncio @@ -1284,7 +1284,7 @@ async def test_paste_image_prefers_png(): ) as mock_img: result = await clipboard_paste() - mock_img.assert_called_once_with("image/png") + mock_img.assert_called_once_with("image/png", "clipboard") assert isinstance(result, Image) @@ -1301,7 +1301,7 @@ async def test_paste_image_falls_back_to_first(): ) as mock_img: result = await clipboard_paste() - mock_img.assert_called_once_with("image/tiff") + mock_img.assert_called_once_with("image/tiff", "clipboard") assert isinstance(result, Image) @@ -1501,7 +1501,7 @@ async def test_read_clipboard_image_dispatches(): result = await read_clipboard_image("image/png") assert result == b"IMG" - mock_reader.assert_called_once_with("image/png") + mock_reader.assert_called_once_with("image/png", "clipboard") # --------------------------------------------------------------------------- @@ -1614,7 +1614,7 @@ async def test_read_clipboard_falls_back_to_suffixed_mime(): """read_clipboard retries with the suffixed MIME type when exact match fails.""" call_log = [] - async def mock_reader(mime_type): + async def mock_reader(mime_type, selection="clipboard"): call_log.append(mime_type) if mime_type == "text/plain": return "" # exact match fails @@ -1622,7 +1622,7 @@ async def mock_reader(mime_type): return "hello from charset" return "" - async def mock_list(): + async def mock_list(selection="clipboard"): return ["text/plain;charset=utf-8", "text/html"] with patch("mcp_clipboard.clipboard._get_backend", return_value="wayland"): @@ -1640,10 +1640,10 @@ async def test_read_clipboard_no_fallback_when_exact_match_works(): """read_clipboard does not list formats when the exact MIME type succeeds.""" list_called = False - async def mock_reader(mime_type): + async def mock_reader(mime_type, selection="clipboard"): return "direct content" - async def mock_list(): + async def mock_list(selection="clipboard"): nonlocal list_called list_called = True return [] @@ -1661,12 +1661,12 @@ async def mock_list(): async def test_read_clipboard_no_fallback_on_macos(): """read_clipboard skips MIME fallback on macOS (not applicable).""" - async def mock_reader(mime_type): + async def mock_reader(mime_type, selection="clipboard"): return "" list_called = False - async def mock_list(): + async def mock_list(selection="clipboard"): nonlocal list_called list_called = True return ["text/plain;charset=utf-8"] @@ -1684,14 +1684,14 @@ async def mock_list(): async def test_read_clipboard_image_falls_back_to_suffixed_mime(): """read_clipboard_image retries with suffixed MIME type on fallback.""" - async def mock_reader(mime_type): + async def mock_reader(mime_type, selection="clipboard"): if mime_type == "image/png": return b"" if mime_type == "image/png;charset=binary": return b"\x89PNG" return b"" - async def mock_list(): + async def mock_list(selection="clipboard"): return ["image/png;charset=binary"] with patch("mcp_clipboard.clipboard._get_backend", return_value="x11"): @@ -1738,7 +1738,7 @@ async def test_paste_image_prefers_png_with_params(): result = await clipboard_paste() # Should use the suffixed PNG format, not fall back to tiff - mock_img.assert_called_once_with("image/png;charset=binary") + mock_img.assert_called_once_with("image/png;charset=binary", "clipboard") assert isinstance(result, Image) @@ -2166,7 +2166,7 @@ async def test_paste_rtf_fallback(): """clipboard_paste returns RTF content when HTML and plain text are empty.""" rtf_content = r"{\rtf1\ansi Hello, {\b world}!}" - async def mock_read(mime_type="text/plain"): + async def mock_read(mime_type="text/plain", selection="clipboard"): if mime_type == "text/rtf": return rtf_content return "" @@ -2185,7 +2185,7 @@ async def test_paste_rtf_with_triple_backticks_uses_longer_fence(): """RTF content containing ``` must not break out of the markdown fence.""" rtf_content = r"{\rtf1\ansi look: ```escape```}" - async def mock_read(mime_type="text/plain"): + async def mock_read(mime_type="text/plain", selection="clipboard"): if mime_type == "text/rtf": return rtf_content return "" @@ -2203,7 +2203,7 @@ async def test_paste_rtf_truncated(): """clipboard_paste truncates oversized RTF at 50KB.""" rtf_content = r"{\rtf1\ansi " + ("x" * 60_000) + "}" - async def mock_read(mime_type="text/plain"): + async def mock_read(mime_type="text/plain", selection="clipboard"): if mime_type == "text/rtf": return rtf_content return "" @@ -2220,7 +2220,7 @@ async def mock_read(mime_type="text/plain"): async def test_paste_rtf_skipped_when_text_present(): """clipboard_paste does not attempt RTF read when plain text is available.""" - async def mock_read(mime_type="text/plain"): + async def mock_read(mime_type="text/plain", selection="clipboard"): if mime_type == "text/plain": return "hello world" if mime_type == "text/rtf": @@ -2237,7 +2237,7 @@ async def mock_read(mime_type="text/plain"): async def test_paste_rtf_error_falls_through(): """clipboard_paste falls through to binary check when RTF read raises ClipboardError.""" - async def mock_read(mime_type="text/plain"): + async def mock_read(mime_type="text/plain", selection="clipboard"): if mime_type == "text/rtf": raise ClipboardError("rtf not available") return "" @@ -2258,12 +2258,12 @@ async def mock_read(mime_type="text/plain"): async def test_read_clipboard_image_no_fallback_on_macos(): """read_clipboard_image skips MIME fallback on macOS.""" - async def mock_reader(mime_type): + async def mock_reader(mime_type, selection="clipboard"): return b"" list_called = False - async def mock_list(): + async def mock_list(selection="clipboard"): nonlocal list_called list_called = True return ["image/png;charset=binary"] @@ -2281,12 +2281,12 @@ async def mock_list(): async def test_read_clipboard_image_no_fallback_on_windows(): """read_clipboard_image skips MIME fallback on Windows.""" - async def mock_reader(mime_type): + async def mock_reader(mime_type, selection="clipboard"): return b"" list_called = False - async def mock_list(): + async def mock_list(selection="clipboard"): nonlocal list_called list_called = True return ["image/png;charset=binary"] @@ -2304,10 +2304,10 @@ async def mock_list(): async def test_read_clipboard_image_fallback_no_match(): """read_clipboard_image returns empty bytes when fallback finds no matching base type.""" - async def mock_reader(mime_type): + async def mock_reader(mime_type, selection="clipboard"): return b"" - async def mock_list(): + async def mock_list(selection="clipboard"): return ["image/tiff", "text/plain"] with patch("mcp_clipboard.clipboard._get_backend", return_value="wayland"): @@ -3635,6 +3635,292 @@ async def test_clipboard_copy_markdown_surfaces_render_error(): assert "parser exploded" in result +# --------------------------------------------------------------------------- +# PRIMARY selection support (#110) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_wayland_read_passes_primary_flag(): + """_wayland_read appends --primary when selection='primary'.""" + from mcp_clipboard.clipboard import _wayland_read + + with patch("mcp_clipboard.clipboard._run", new_callable=AsyncMock) as mock: + mock.return_value = "selected text" + await _wayland_read("text/plain", "primary") + + cmd = mock.call_args[0][0] + assert "--primary" in cmd + assert "wl-paste" in cmd[0] + + +@pytest.mark.asyncio +async def test_wayland_read_default_clipboard_omits_primary_flag(): + from mcp_clipboard.clipboard import _wayland_read + + with patch("mcp_clipboard.clipboard._run", new_callable=AsyncMock) as mock: + mock.return_value = "" + await _wayland_read("text/plain") + + cmd = mock.call_args[0][0] + assert "--primary" not in cmd + + +@pytest.mark.asyncio +async def test_wayland_list_formats_primary(): + from mcp_clipboard.clipboard import _wayland_list_formats + + with patch("mcp_clipboard.clipboard._run", new_callable=AsyncMock) as mock: + mock.return_value = "text/plain\n" + await _wayland_list_formats("primary") + + cmd = mock.call_args[0][0] + assert "--primary" in cmd + assert "--list-types" in cmd + + +@pytest.mark.asyncio +async def test_wayland_read_image_primary(): + from mcp_clipboard.clipboard import _wayland_read_image + + with patch("mcp_clipboard.clipboard._run_binary", new_callable=AsyncMock) as mock: + mock.return_value = b"" + await _wayland_read_image("image/png", "primary") + + cmd = mock.call_args[0][0] + assert "--primary" in cmd + + +@pytest.mark.asyncio +async def test_x11_read_uses_primary_selection_arg(): + """_x11_read passes -selection primary when selection='primary'.""" + from mcp_clipboard.clipboard import _x11_read + + with patch("mcp_clipboard.clipboard._run", new_callable=AsyncMock) as mock: + mock.return_value = "" + await _x11_read("text/plain", "primary") + + cmd = mock.call_args[0][0] + assert cmd[0] == "xclip" + # `-selection primary` must be the consecutive args. + sel_idx = cmd.index("-selection") + assert cmd[sel_idx + 1] == "primary" + + +@pytest.mark.asyncio +async def test_x11_read_default_uses_clipboard_selection(): + from mcp_clipboard.clipboard import _x11_read + + with patch("mcp_clipboard.clipboard._run", new_callable=AsyncMock) as mock: + mock.return_value = "" + await _x11_read("text/plain") + + cmd = mock.call_args[0][0] + sel_idx = cmd.index("-selection") + assert cmd[sel_idx + 1] == "clipboard" + + +@pytest.mark.asyncio +async def test_x11_list_formats_primary(): + from mcp_clipboard.clipboard import _x11_list_formats + + with patch("mcp_clipboard.clipboard._run", new_callable=AsyncMock) as mock: + mock.return_value = "" + await _x11_list_formats("primary") + + cmd = mock.call_args[0][0] + sel_idx = cmd.index("-selection") + assert cmd[sel_idx + 1] == "primary" + + +@pytest.mark.asyncio +async def test_x11_read_image_primary(): + from mcp_clipboard.clipboard import _x11_read_image + + with patch("mcp_clipboard.clipboard._run_binary", new_callable=AsyncMock) as mock: + mock.return_value = b"" + await _x11_read_image("image/png", "primary") + + cmd = mock.call_args[0][0] + sel_idx = cmd.index("-selection") + assert cmd[sel_idx + 1] == "primary" + + +@pytest.mark.asyncio +async def test_macos_read_rejects_primary(): + """macOS has no PRIMARY analog; non-default selection raises ClipboardError.""" + from mcp_clipboard.clipboard import _macos_read + + with pytest.raises(ClipboardError, match="macOS does not support"): + await _macos_read("text/plain", "primary") + + +@pytest.mark.asyncio +async def test_windows_read_rejects_primary(): + from mcp_clipboard.clipboard import _windows_read + + with pytest.raises(ClipboardError, match="Windows does not support"): + await _windows_read("text/plain", "primary") + + +@pytest.mark.asyncio +async def test_macos_list_formats_rejects_primary(): + from mcp_clipboard.clipboard import _macos_list_formats + + with pytest.raises(ClipboardError, match="macOS"): + await _macos_list_formats("primary") + + +@pytest.mark.asyncio +async def test_windows_list_formats_rejects_primary(): + from mcp_clipboard.clipboard import _windows_list_formats + + with pytest.raises(ClipboardError, match="Windows"): + await _windows_list_formats("primary") + + +@pytest.mark.asyncio +async def test_macos_read_image_rejects_primary(): + from mcp_clipboard.clipboard import _macos_read_image + + with pytest.raises(ClipboardError, match="macOS"): + await _macos_read_image("image/png", "primary") + + +@pytest.mark.asyncio +async def test_windows_read_image_rejects_primary(): + from mcp_clipboard.clipboard import _windows_read_image + + with pytest.raises(ClipboardError, match="Windows"): + await _windows_read_image("image/png", "primary") + + +@pytest.mark.asyncio +async def test_invalid_selection_raises_at_backend_layer(): + """Backend selection validation rejects unknown values cleanly on Wayland.""" + from mcp_clipboard.clipboard import _wayland_read + + with pytest.raises(ClipboardError, match="Invalid selection"): + await _wayland_read("text/plain", "secondary") + + +# --- Public API threading --- + + +@pytest.mark.asyncio +async def test_read_clipboard_passes_selection_through(): + mock_reader = AsyncMock(return_value="primary text") + with ( + patch("mcp_clipboard.clipboard._get_backend", return_value="wayland"), + patch.dict("mcp_clipboard.clipboard._READERS", {"wayland": mock_reader}), + ): + result = await read_clipboard("text/plain", "primary") + + assert result == "primary text" + mock_reader.assert_called_once_with("text/plain", "primary") + + +@pytest.mark.asyncio +async def test_list_clipboard_formats_passes_selection_through(): + mock_lister = AsyncMock(return_value=["text/plain"]) + with ( + patch("mcp_clipboard.clipboard._get_backend", return_value="x11"), + patch.dict("mcp_clipboard.clipboard._FORMAT_LISTERS", {"x11": mock_lister}), + ): + await list_clipboard_formats("primary") + + mock_lister.assert_called_once_with("primary") + + +@pytest.mark.asyncio +async def test_read_clipboard_image_passes_selection_through(): + mock_reader = AsyncMock(return_value=b"") + with ( + patch("mcp_clipboard.clipboard._get_backend", return_value="x11"), + patch.dict("mcp_clipboard.clipboard._IMAGE_READERS", {"x11": mock_reader}), + patch.dict( + "mcp_clipboard.clipboard._FORMAT_LISTERS", + {"x11": AsyncMock(return_value=[])}, + ), + ): + await read_clipboard_image("image/png", "primary") + + mock_reader.assert_called_once_with("image/png", "primary") + + +# --- Tool surface --- + + +@pytest.mark.asyncio +async def test_clipboard_paste_accepts_selection_primary(): + """clipboard_paste(selection='primary') routes the read through with primary.""" + with patch("mcp_clipboard.server.read_clipboard", new=_mock_read(text="hello primary")): + result = await clipboard_paste(selection="primary") + assert "hello primary" in result + + +@pytest.mark.asyncio +async def test_clipboard_paste_threads_selection_to_read_clipboard(): + """clipboard_paste must pass selection to its read_clipboard calls so the + correct buffer is sourced.""" + seen: list[tuple[str, str]] = [] + + async def spy(mime_type: str = "text/plain", selection: str = "clipboard") -> str: + seen.append((mime_type, selection)) + return "" + + with patch("mcp_clipboard.server.read_clipboard", new=spy): + await clipboard_paste(selection="primary") + + assert all(s == "primary" for _, s in seen) + assert any(m == "text/html" for m, _ in seen) + assert any(m == "text/plain" for m, _ in seen) + + +@pytest.mark.asyncio +async def test_clipboard_paste_rejects_invalid_selection(): + result = await clipboard_paste(selection="secondary") + assert "Invalid selection" in result + + +@pytest.mark.asyncio +async def test_clipboard_read_raw_accepts_primary(): + with patch("mcp_clipboard.server.read_clipboard", return_value="from primary") as mock: + result = await clipboard_read_raw(mime_type="text/plain", selection="primary") + + assert "from primary" in result + mock.assert_called_once_with("text/plain", "primary") + + +@pytest.mark.asyncio +async def test_clipboard_read_raw_rejects_invalid_selection(): + result = await clipboard_read_raw(mime_type="text/plain", selection="bogus") + assert "Invalid selection" in result + + +@pytest.mark.asyncio +async def test_clipboard_list_formats_accepts_primary(): + with patch("mcp_clipboard.server.list_clipboard_formats", return_value=["text/plain"]) as mock: + result = await clipboard_list_formats(selection="primary") + + assert "text/plain" in result + mock.assert_called_once_with("primary") + + +@pytest.mark.asyncio +async def test_clipboard_list_formats_rejects_invalid_selection(): + result = await clipboard_list_formats(selection="bogus") + assert "Invalid selection" in result + + +@pytest.mark.asyncio +async def test_clipboard_paste_normalizes_selection_case(): + with patch("mcp_clipboard.server.read_clipboard", new=_mock_read(text="x")): + result = await clipboard_paste(selection=" PRIMARY ") + # Normalized to lowercase; would have been rejected as "Invalid" otherwise. + assert "Invalid selection" not in result + + # --------------------------------------------------------------------------- # __version__ resilience (#28) # ---------------------------------------------------------------------------