Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
117 changes: 86 additions & 31 deletions src/mcp_clipboard/clipboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down Expand Up @@ -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"
Expand All @@ -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 = (
Expand Down Expand Up @@ -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()"
Expand All @@ -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/")}
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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.
Expand All @@ -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

Expand Down
6 changes: 6 additions & 0 deletions src/mcp_clipboard/instructions/clipboard_list_formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
5 changes: 5 additions & 0 deletions src/mcp_clipboard/instructions/clipboard_paste.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions src/mcp_clipboard/instructions/clipboard_read_raw.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Loading
Loading