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.
@@ -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,2 @@
1
+ <!-- uv init -->
2
+ <!-- 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
+
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,2 @@
1
+ def main() -> None:
2
+ print("Hello from prod-mcp-test!")
@@ -0,0 +1,5 @@
1
+ from clipboard_handlerv .tools import mcp
2
+
3
+ def main():
4
+ mcp.run(transport="stdio")
5
+ # print(mcp)
@@ -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,2 @@
1
+ [console_scripts]
2
+ clipboard_handler = clipboard_handler.main:main
@@ -0,0 +1,2 @@
1
+ __init__
2
+ clipboard_handler