clipboard_handler 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- clipboard_handler-0.1.0/PKG-INFO +10 -0
- clipboard_handler-0.1.0/README.md +2 -0
- clipboard_handler-0.1.0/pyproject.toml +21 -0
- clipboard_handler-0.1.0/setup.cfg +4 -0
- clipboard_handler-0.1.0/src/__init__.py +2 -0
- clipboard_handler-0.1.0/src/clipboard_handler/__init__.py +0 -0
- clipboard_handler-0.1.0/src/clipboard_handler/main.py +5 -0
- clipboard_handler-0.1.0/src/clipboard_handler/tools.py +306 -0
- clipboard_handler-0.1.0/src/clipboard_handler.egg-info/PKG-INFO +10 -0
- clipboard_handler-0.1.0/src/clipboard_handler.egg-info/SOURCES.txt +11 -0
- clipboard_handler-0.1.0/src/clipboard_handler.egg-info/dependency_links.txt +1 -0
- clipboard_handler-0.1.0/src/clipboard_handler.egg-info/entry_points.txt +2 -0
- clipboard_handler-0.1.0/src/clipboard_handler.egg-info/top_level.txt +2 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: clipboard_handler
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Clipboard handler server
|
|
5
|
+
Author-email: Daniel-Adepoju <dannymay116@gmail.com>
|
|
6
|
+
Requires-Python: >=3.14
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
|
|
9
|
+
<!-- uv init -->
|
|
10
|
+
<!-- uv build -->
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "clipboard_handler"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Clipboard handler server"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Daniel-Adepoju", email = "dannymay116@gmail.com" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.14"
|
|
10
|
+
dependencies = []
|
|
11
|
+
|
|
12
|
+
[build-system]
|
|
13
|
+
requires = ["setuptools>42", "wheel"]
|
|
14
|
+
build-backend = "setuptools.build_meta"
|
|
15
|
+
|
|
16
|
+
[tools.setuptools.packages.find]
|
|
17
|
+
where = ["src"]
|
|
18
|
+
|
|
19
|
+
[project.scripts]
|
|
20
|
+
clipboard_handler = "clipboard_handler.main:main"
|
|
21
|
+
|
|
File without changes
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
|
|
2
|
+
"""
|
|
3
|
+
Clipboard History MCP Server (FastMCP)
|
|
4
|
+
|
|
5
|
+
Tools:
|
|
6
|
+
- get_clipboard : current clipboard text
|
|
7
|
+
- set_clipboard : set clipboard text (also pushed to history)
|
|
8
|
+
- list_history : list recent clipboard entries
|
|
9
|
+
- get_history_item : get one history entry by index
|
|
10
|
+
- copy_from_history : copy a history entry back to the clipboard
|
|
11
|
+
- clear_history : wipe stored history
|
|
12
|
+
- search_history : search history by substring
|
|
13
|
+
|
|
14
|
+
Requires: pip install fastmcp pyperclip
|
|
15
|
+
Optional (better Windows support): pip install pywin32
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import threading
|
|
22
|
+
import time
|
|
23
|
+
from collections import deque
|
|
24
|
+
from dataclasses import asdict, dataclass
|
|
25
|
+
from datetime import datetime, timezone
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from fastmcp import FastMCP
|
|
30
|
+
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
# Config
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
MAX_HISTORY = 50
|
|
36
|
+
HISTORY_FILE = Path.home() / ".clipboard_mcp_history.json"
|
|
37
|
+
POLL_INTERVAL_SEC = 0.8 # background watcher interval
|
|
38
|
+
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
# Clipboard backend
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
def _get_clipboard() -> str:
|
|
44
|
+
try:
|
|
45
|
+
import pyperclip
|
|
46
|
+
return pyperclip.paste() or ""
|
|
47
|
+
except Exception as e:
|
|
48
|
+
return f"[clipboard error: {e}]"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _set_clipboard(text: str) -> None:
|
|
52
|
+
import pyperclip
|
|
53
|
+
pyperclip.copy(text)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
# History store
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class HistoryEntry:
|
|
62
|
+
id: int
|
|
63
|
+
text: str
|
|
64
|
+
timestamp: str # ISO-8601 UTC
|
|
65
|
+
source: str = "watch" # "watch" | "set" | "restore"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ClipboardHistory:
|
|
69
|
+
def __init__(self, max_size: int = MAX_HISTORY, path: Path = HISTORY_FILE):
|
|
70
|
+
self.max_size = max_size
|
|
71
|
+
self.path = path
|
|
72
|
+
self._lock = threading.Lock()
|
|
73
|
+
self._entries: deque[HistoryEntry] = deque(maxlen=max_size)
|
|
74
|
+
self._next_id = 1
|
|
75
|
+
self._last_seen = ""
|
|
76
|
+
self._load()
|
|
77
|
+
|
|
78
|
+
def _load(self) -> None:
|
|
79
|
+
if not self.path.exists():
|
|
80
|
+
return
|
|
81
|
+
try:
|
|
82
|
+
data = json.loads(self.path.read_text(encoding="utf-8"))
|
|
83
|
+
self._next_id = data.get("next_id", 1)
|
|
84
|
+
for item in data.get("entries", []):
|
|
85
|
+
self._entries.append(HistoryEntry(**item))
|
|
86
|
+
if self._entries:
|
|
87
|
+
self._last_seen = self._entries[-1].text
|
|
88
|
+
except Exception:
|
|
89
|
+
pass
|
|
90
|
+
|
|
91
|
+
def _save(self) -> None:
|
|
92
|
+
try:
|
|
93
|
+
payload = {
|
|
94
|
+
"next_id": self._next_id,
|
|
95
|
+
"entries": [asdict(e) for e in self._entries],
|
|
96
|
+
}
|
|
97
|
+
self.path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
98
|
+
except Exception:
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
def push(self, text: str, source: str = "watch") -> HistoryEntry | None:
|
|
102
|
+
text = (text or "").strip("\x00") # strip nulls
|
|
103
|
+
if not text:
|
|
104
|
+
return None
|
|
105
|
+
with self._lock:
|
|
106
|
+
# Deduplicate consecutive identical entries
|
|
107
|
+
if self._entries and self._entries[-1].text == text:
|
|
108
|
+
return None
|
|
109
|
+
entry = HistoryEntry(
|
|
110
|
+
id=self._next_id,
|
|
111
|
+
text=text,
|
|
112
|
+
timestamp=datetime.now(timezone.utc).isoformat(),
|
|
113
|
+
source=source,
|
|
114
|
+
)
|
|
115
|
+
self._next_id += 1
|
|
116
|
+
self._entries.append(entry)
|
|
117
|
+
self._last_seen = text
|
|
118
|
+
self._save()
|
|
119
|
+
return entry
|
|
120
|
+
|
|
121
|
+
def list(self, limit: int = 20) -> list[dict[str, Any]]:
|
|
122
|
+
with self._lock:
|
|
123
|
+
items = list(self._entries)[-limit:]
|
|
124
|
+
# newest first
|
|
125
|
+
items = list(reversed(items))
|
|
126
|
+
return [
|
|
127
|
+
{
|
|
128
|
+
"id": e.id,
|
|
129
|
+
"preview": e.text[:120] + ("…" if len(e.text) > 120 else ""),
|
|
130
|
+
"length": len(e.text),
|
|
131
|
+
"timestamp": e.timestamp,
|
|
132
|
+
"source": e.source,
|
|
133
|
+
}
|
|
134
|
+
for e in items
|
|
135
|
+
]
|
|
136
|
+
|
|
137
|
+
def get(self, index: int = 0) -> HistoryEntry | None:
|
|
138
|
+
"""index 0 = newest, 1 = previous, …"""
|
|
139
|
+
with self._lock:
|
|
140
|
+
if not self._entries:
|
|
141
|
+
return None
|
|
142
|
+
items = list(reversed(self._entries))
|
|
143
|
+
if index < 0 or index >= len(items):
|
|
144
|
+
return None
|
|
145
|
+
return items[index]
|
|
146
|
+
|
|
147
|
+
def get_by_id(self, entry_id: int) -> HistoryEntry | None:
|
|
148
|
+
with self._lock:
|
|
149
|
+
for e in self._entries:
|
|
150
|
+
if e.id == entry_id:
|
|
151
|
+
return e
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
def search(self, query: str, limit: int = 20) -> list[dict[str, Any]]:
|
|
155
|
+
q = query.lower()
|
|
156
|
+
with self._lock:
|
|
157
|
+
hits = [e for e in reversed(self._entries) if q in e.text.lower()]
|
|
158
|
+
hits = hits[:limit]
|
|
159
|
+
return [
|
|
160
|
+
{
|
|
161
|
+
"id": e.id,
|
|
162
|
+
"preview": e.text[:120] + ("…" if len(e.text) > 120 else ""),
|
|
163
|
+
"length": len(e.text),
|
|
164
|
+
"timestamp": e.timestamp,
|
|
165
|
+
}
|
|
166
|
+
for e in hits
|
|
167
|
+
]
|
|
168
|
+
|
|
169
|
+
def clear(self) -> int:
|
|
170
|
+
with self._lock:
|
|
171
|
+
n = len(self._entries)
|
|
172
|
+
self._entries.clear()
|
|
173
|
+
self._save()
|
|
174
|
+
return n
|
|
175
|
+
|
|
176
|
+
def watch_once(self) -> None:
|
|
177
|
+
"""Poll clipboard once and push if changed."""
|
|
178
|
+
current = _get_clipboard()
|
|
179
|
+
if current and current != self._last_seen:
|
|
180
|
+
self.push(current, source="watch")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
history = ClipboardHistory()
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _start_watcher() -> None:
|
|
187
|
+
def loop() -> None:
|
|
188
|
+
while True:
|
|
189
|
+
try:
|
|
190
|
+
history.watch_once()
|
|
191
|
+
except Exception:
|
|
192
|
+
pass
|
|
193
|
+
time.sleep(POLL_INTERVAL_SEC)
|
|
194
|
+
|
|
195
|
+
t = threading.Thread(target=loop, daemon=True, name="clipboard-watcher")
|
|
196
|
+
t.start()
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
_start_watcher()
|
|
200
|
+
|
|
201
|
+
# ---------------------------------------------------------------------------
|
|
202
|
+
# FastMCP server
|
|
203
|
+
# ---------------------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
mcp = FastMCP(
|
|
206
|
+
name="Clipboard History",
|
|
207
|
+
instructions=(
|
|
208
|
+
"Clipboard history tools. The server continuously watches the system "
|
|
209
|
+
"clipboard and keeps the last 50 unique entries. Use list_history / "
|
|
210
|
+
"search_history to find past clips, then copy_from_history to restore one."
|
|
211
|
+
),
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@mcp.tool()
|
|
216
|
+
def get_clipboard() -> str:
|
|
217
|
+
"""Return the current system clipboard text."""
|
|
218
|
+
return _get_clipboard()
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@mcp.tool()
|
|
222
|
+
def set_clipboard(text: str) -> str:
|
|
223
|
+
"""
|
|
224
|
+
Set the system clipboard to the given text.
|
|
225
|
+
The text is also added to clipboard history.
|
|
226
|
+
"""
|
|
227
|
+
_set_clipboard(text)
|
|
228
|
+
entry = history.push(text, source="set")
|
|
229
|
+
if entry:
|
|
230
|
+
return f"Clipboard set (history id={entry.id}, {len(text)} chars)"
|
|
231
|
+
return f"Clipboard set ({len(text)} chars, duplicate ignored)"
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@mcp.tool()
|
|
235
|
+
def list_history(limit: int = 20) -> list[dict[str, Any]]:
|
|
236
|
+
"""
|
|
237
|
+
List recent clipboard history entries (newest first).
|
|
238
|
+
|
|
239
|
+
Args:
|
|
240
|
+
limit: Max number of entries to return (default 20, max 50).
|
|
241
|
+
"""
|
|
242
|
+
limit = max(1, min(limit, MAX_HISTORY))
|
|
243
|
+
return history.list(limit=limit)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
@mcp.tool()
|
|
247
|
+
def get_history_item(index: int = 0) -> dict[str, Any]:
|
|
248
|
+
"""
|
|
249
|
+
Get full text of a history entry by position.
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
index: 0 = newest, 1 = previous, 2 = older, …
|
|
253
|
+
"""
|
|
254
|
+
entry = history.get(index)
|
|
255
|
+
if not entry:
|
|
256
|
+
return {"error": f"No history entry at index {index}"}
|
|
257
|
+
return {
|
|
258
|
+
"id": entry.id,
|
|
259
|
+
"text": entry.text,
|
|
260
|
+
"timestamp": entry.timestamp,
|
|
261
|
+
"source": entry.source,
|
|
262
|
+
"length": len(entry.text),
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
@mcp.tool()
|
|
267
|
+
def copy_from_history(index: int = 0) -> str:
|
|
268
|
+
"""
|
|
269
|
+
Copy a history entry back onto the system clipboard.
|
|
270
|
+
|
|
271
|
+
Args:
|
|
272
|
+
index: 0 = newest, 1 = previous, …
|
|
273
|
+
"""
|
|
274
|
+
entry = history.get(index)
|
|
275
|
+
if not entry:
|
|
276
|
+
return f"No history entry at index {index}"
|
|
277
|
+
_set_clipboard(entry.text)
|
|
278
|
+
history.push(entry.text, source="restore")
|
|
279
|
+
preview = entry.text[:80] + ("…" if len(entry.text) > 80 else "")
|
|
280
|
+
return f"Restored to clipboard (id={entry.id}): {preview}"
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
@mcp.tool()
|
|
284
|
+
def search_history(query: str, limit: int = 20) -> list[dict[str, Any]]:
|
|
285
|
+
"""
|
|
286
|
+
Search clipboard history for entries containing the query (case-insensitive).
|
|
287
|
+
|
|
288
|
+
Args:
|
|
289
|
+
query: Substring to search for.
|
|
290
|
+
limit: Max results (default 20).
|
|
291
|
+
"""
|
|
292
|
+
if not query.strip():
|
|
293
|
+
return []
|
|
294
|
+
return history.search(query, limit=limit)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
@mcp.tool()
|
|
298
|
+
def clear_history() -> str:
|
|
299
|
+
"""Clear all stored clipboard history (does not change the current clipboard)."""
|
|
300
|
+
n = history.clear()
|
|
301
|
+
return f"Cleared {n} history entries"
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
# if __name__ == "__main__":
|
|
305
|
+
# # stdio is the default for MCP subprocess servers
|
|
306
|
+
# mcp.run()
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: clipboard_handler
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Clipboard handler server
|
|
5
|
+
Author-email: Daniel-Adepoju <dannymay116@gmail.com>
|
|
6
|
+
Requires-Python: >=3.14
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
|
|
9
|
+
<!-- uv init -->
|
|
10
|
+
<!-- uv build -->
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/__init__.py
|
|
4
|
+
src/clipboard_handler/__init__.py
|
|
5
|
+
src/clipboard_handler/main.py
|
|
6
|
+
src/clipboard_handler/tools.py
|
|
7
|
+
src/clipboard_handler.egg-info/PKG-INFO
|
|
8
|
+
src/clipboard_handler.egg-info/SOURCES.txt
|
|
9
|
+
src/clipboard_handler.egg-info/dependency_links.txt
|
|
10
|
+
src/clipboard_handler.egg-info/entry_points.txt
|
|
11
|
+
src/clipboard_handler.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|