remarkable-mcp 0.1.3__py3-none-any.whl
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.
- remarkable_mcp/__init__.py +17 -0
- remarkable_mcp/api.py +116 -0
- remarkable_mcp/extract.py +183 -0
- remarkable_mcp/prompts.py +134 -0
- remarkable_mcp/resources.py +130 -0
- remarkable_mcp/responses.py +24 -0
- remarkable_mcp/server.py +20 -0
- remarkable_mcp/tools.py +432 -0
- remarkable_mcp-0.1.3.dist-info/METADATA +214 -0
- remarkable_mcp-0.1.3.dist-info/RECORD +13 -0
- remarkable_mcp-0.1.3.dist-info/WHEEL +4 -0
- remarkable_mcp-0.1.3.dist-info/entry_points.txt +2 -0
- remarkable_mcp-0.1.3.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""
|
|
2
|
+
reMarkable MCP Server
|
|
3
|
+
|
|
4
|
+
An MCP server that provides access to reMarkable tablet data through the reMarkable Cloud API.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
__version__ = "0.1.0"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def get_mcp():
|
|
11
|
+
"""Get the MCP server instance. Only imports when called."""
|
|
12
|
+
from remarkable_mcp.server import mcp
|
|
13
|
+
|
|
14
|
+
return mcp
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
__all__ = ["get_mcp", "__version__"]
|
remarkable_mcp/api.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""
|
|
2
|
+
reMarkable Cloud API client helpers.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Dict, List
|
|
8
|
+
|
|
9
|
+
# Configuration - check env var first, then fall back to file
|
|
10
|
+
REMARKABLE_TOKEN = os.environ.get("REMARKABLE_TOKEN")
|
|
11
|
+
REMARKABLE_CONFIG_DIR = Path.home() / ".remarkable"
|
|
12
|
+
REMARKABLE_TOKEN_FILE = REMARKABLE_CONFIG_DIR / "token"
|
|
13
|
+
CACHE_DIR = REMARKABLE_CONFIG_DIR / "cache"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_rmapi():
|
|
17
|
+
"""Get or initialize the reMarkable API client."""
|
|
18
|
+
try:
|
|
19
|
+
from rmapy.api import Client
|
|
20
|
+
|
|
21
|
+
client = Client()
|
|
22
|
+
|
|
23
|
+
# If token is provided via environment, use it
|
|
24
|
+
if REMARKABLE_TOKEN:
|
|
25
|
+
# rmapy stores token in ~/.rmapi, we need to write it there
|
|
26
|
+
rmapi_file = Path.home() / ".rmapi"
|
|
27
|
+
rmapi_file.write_text(REMARKABLE_TOKEN)
|
|
28
|
+
|
|
29
|
+
# Renew/validate the token
|
|
30
|
+
client.renew_token()
|
|
31
|
+
|
|
32
|
+
return client
|
|
33
|
+
except ImportError:
|
|
34
|
+
raise RuntimeError("rmapy not installed. Run: uv add rmapy")
|
|
35
|
+
except Exception as e:
|
|
36
|
+
raise RuntimeError(f"Failed to initialize reMarkable client: {e}")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def ensure_config_dir():
|
|
40
|
+
"""Ensure configuration directory exists."""
|
|
41
|
+
REMARKABLE_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def register_and_get_token(one_time_code: str) -> str:
|
|
46
|
+
"""
|
|
47
|
+
Register with reMarkable using a one-time code and return the token.
|
|
48
|
+
|
|
49
|
+
Get a code from: https://my.remarkable.com/device/desktop/connect
|
|
50
|
+
"""
|
|
51
|
+
import json as json_module
|
|
52
|
+
from uuid import uuid4
|
|
53
|
+
|
|
54
|
+
import requests
|
|
55
|
+
|
|
56
|
+
# Use the current remarkable API endpoint
|
|
57
|
+
# (rmapy uses an outdated one, this is from ddvk/rmapi)
|
|
58
|
+
device_token_url = "https://webapp-prod.cloud.remarkable.engineering/token/json/2/device/new"
|
|
59
|
+
uuid = str(uuid4())
|
|
60
|
+
body = {
|
|
61
|
+
"code": one_time_code,
|
|
62
|
+
"deviceDesc": "desktop-linux",
|
|
63
|
+
"deviceID": uuid,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
response = requests.post(device_token_url, json=body)
|
|
68
|
+
|
|
69
|
+
if response.status_code == 200 and response.text:
|
|
70
|
+
# Got a device token, save it in rmapy format
|
|
71
|
+
device_token = response.text.strip()
|
|
72
|
+
|
|
73
|
+
# rmapy expects a JSON file with devicetoken and usertoken
|
|
74
|
+
rmapi_file = Path.home() / ".rmapi"
|
|
75
|
+
token_data = {"devicetoken": device_token, "usertoken": ""}
|
|
76
|
+
rmapi_file.write_text(json_module.dumps(token_data))
|
|
77
|
+
|
|
78
|
+
return json_module.dumps(token_data)
|
|
79
|
+
else:
|
|
80
|
+
raise RuntimeError(
|
|
81
|
+
f"Registration failed (HTTP {response.status_code})\n\n"
|
|
82
|
+
"This usually means:\n"
|
|
83
|
+
" 1. The code has expired (codes are single-use and expire quickly)\n"
|
|
84
|
+
" 2. The code was already used\n"
|
|
85
|
+
" 3. The code was typed incorrectly\n\n"
|
|
86
|
+
"Get a new code from: https://my.remarkable.com/device/desktop/connect"
|
|
87
|
+
)
|
|
88
|
+
except requests.RequestException as e:
|
|
89
|
+
raise RuntimeError(f"Network error during registration: {e}")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def get_items_by_id(collection) -> Dict[str, Any]:
|
|
93
|
+
"""Build a lookup dict of items by ID."""
|
|
94
|
+
return {item.ID: item for item in collection}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def get_items_by_parent(collection) -> Dict[str, List]:
|
|
98
|
+
"""Build a lookup dict of items grouped by parent ID."""
|
|
99
|
+
items_by_parent: Dict[str, List] = {}
|
|
100
|
+
for item in collection:
|
|
101
|
+
parent = item.Parent if hasattr(item, "Parent") else ""
|
|
102
|
+
if parent not in items_by_parent:
|
|
103
|
+
items_by_parent[parent] = []
|
|
104
|
+
items_by_parent[parent].append(item)
|
|
105
|
+
return items_by_parent
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def get_item_path(item, items_by_id: Dict[str, Any]) -> str:
|
|
109
|
+
"""Get the full path of an item."""
|
|
110
|
+
path_parts = [item.VissibleName]
|
|
111
|
+
parent_id = item.Parent if hasattr(item, "Parent") else ""
|
|
112
|
+
while parent_id and parent_id in items_by_id:
|
|
113
|
+
parent = items_by_id[parent_id]
|
|
114
|
+
path_parts.insert(0, parent.VissibleName)
|
|
115
|
+
parent_id = parent.Parent if hasattr(parent, "Parent") else ""
|
|
116
|
+
return "/" + "/".join(path_parts)
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Text extraction helpers for reMarkable documents.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import tempfile
|
|
7
|
+
import zipfile
|
|
8
|
+
from difflib import SequenceMatcher
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Dict, List, Optional
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def find_similar_documents(query: str, documents: List, limit: int = 5) -> List[str]:
|
|
14
|
+
"""Find documents with similar names for 'did you mean' suggestions."""
|
|
15
|
+
query_lower = query.lower()
|
|
16
|
+
scored = []
|
|
17
|
+
for doc in documents:
|
|
18
|
+
name = doc.VissibleName
|
|
19
|
+
# Use sequence matcher for fuzzy matching
|
|
20
|
+
ratio = SequenceMatcher(None, query_lower, name.lower()).ratio()
|
|
21
|
+
# Boost partial matches
|
|
22
|
+
if query_lower in name.lower():
|
|
23
|
+
ratio += 0.3
|
|
24
|
+
scored.append((name, ratio))
|
|
25
|
+
|
|
26
|
+
scored.sort(key=lambda x: x[1], reverse=True)
|
|
27
|
+
return [name for name, score in scored[:limit] if score > 0.3]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def extract_text_from_rm_file(rm_file_path: Path) -> List[str]:
|
|
31
|
+
"""
|
|
32
|
+
Extract typed text from a .rm file using rmscene.
|
|
33
|
+
|
|
34
|
+
This extracts text that was typed via Type Folio or on-screen keyboard.
|
|
35
|
+
Does NOT require OCR - text is stored natively in v6 .rm files.
|
|
36
|
+
"""
|
|
37
|
+
try:
|
|
38
|
+
from rmscene import read_blocks
|
|
39
|
+
from rmscene.scene_items import Text
|
|
40
|
+
from rmscene.scene_tree import SceneTree
|
|
41
|
+
|
|
42
|
+
with open(rm_file_path, "rb") as f:
|
|
43
|
+
tree = SceneTree()
|
|
44
|
+
for block in read_blocks(f):
|
|
45
|
+
tree.add_block(block)
|
|
46
|
+
|
|
47
|
+
text_lines = []
|
|
48
|
+
|
|
49
|
+
# Extract text from the scene tree
|
|
50
|
+
for item in tree.root.children.values():
|
|
51
|
+
if hasattr(item, "value") and isinstance(item.value, Text):
|
|
52
|
+
text_obj = item.value
|
|
53
|
+
if hasattr(text_obj, "items"):
|
|
54
|
+
for text_item in text_obj.items:
|
|
55
|
+
if hasattr(text_item, "value") and text_item.value:
|
|
56
|
+
text_lines.append(str(text_item.value))
|
|
57
|
+
|
|
58
|
+
return text_lines
|
|
59
|
+
|
|
60
|
+
except ImportError:
|
|
61
|
+
return [] # rmscene not available
|
|
62
|
+
except Exception:
|
|
63
|
+
# Log but don't fail - file might be older format
|
|
64
|
+
return []
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def extract_text_from_document_zip(zip_path: Path, include_ocr: bool = False) -> Dict[str, Any]:
|
|
68
|
+
"""
|
|
69
|
+
Extract all text content from a reMarkable document zip.
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
{
|
|
73
|
+
"typed_text": [...], # From rmscene parsing
|
|
74
|
+
"highlights": [...], # From PDF annotations
|
|
75
|
+
"handwritten_text": [...], # From OCR (if enabled)
|
|
76
|
+
"pages": int
|
|
77
|
+
}
|
|
78
|
+
"""
|
|
79
|
+
result: Dict[str, Any] = {
|
|
80
|
+
"typed_text": [],
|
|
81
|
+
"highlights": [],
|
|
82
|
+
"handwritten_text": None,
|
|
83
|
+
"pages": 0,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
87
|
+
tmpdir_path = Path(tmpdir)
|
|
88
|
+
|
|
89
|
+
with zipfile.ZipFile(zip_path, "r") as zf:
|
|
90
|
+
zf.extractall(tmpdir_path)
|
|
91
|
+
|
|
92
|
+
rm_files = list(tmpdir_path.glob("**/*.rm"))
|
|
93
|
+
result["pages"] = len(rm_files)
|
|
94
|
+
|
|
95
|
+
# Extract typed text from .rm files using rmscene
|
|
96
|
+
for rm_file in rm_files:
|
|
97
|
+
text_lines = extract_text_from_rm_file(rm_file)
|
|
98
|
+
result["typed_text"].extend(text_lines)
|
|
99
|
+
|
|
100
|
+
# Extract text from .txt and .md files
|
|
101
|
+
for txt_file in tmpdir_path.glob("**/*.txt"):
|
|
102
|
+
try:
|
|
103
|
+
content = txt_file.read_text(errors="ignore")
|
|
104
|
+
if content.strip():
|
|
105
|
+
result["typed_text"].append(content)
|
|
106
|
+
except Exception:
|
|
107
|
+
pass
|
|
108
|
+
|
|
109
|
+
for md_file in tmpdir_path.glob("**/*.md"):
|
|
110
|
+
try:
|
|
111
|
+
content = md_file.read_text(errors="ignore")
|
|
112
|
+
if content.strip():
|
|
113
|
+
result["typed_text"].append(content)
|
|
114
|
+
except Exception:
|
|
115
|
+
pass
|
|
116
|
+
|
|
117
|
+
# Extract from .content files (metadata with text)
|
|
118
|
+
for content_file in tmpdir_path.glob("**/*.content"):
|
|
119
|
+
try:
|
|
120
|
+
data = json.loads(content_file.read_text())
|
|
121
|
+
if "text" in data:
|
|
122
|
+
result["typed_text"].append(data["text"])
|
|
123
|
+
except Exception:
|
|
124
|
+
pass
|
|
125
|
+
|
|
126
|
+
# Extract PDF highlights
|
|
127
|
+
for json_file in tmpdir_path.glob("**/*.json"):
|
|
128
|
+
try:
|
|
129
|
+
data = json.loads(json_file.read_text())
|
|
130
|
+
if isinstance(data, dict) and "highlights" in data:
|
|
131
|
+
for h in data.get("highlights", []):
|
|
132
|
+
if "text" in h and h["text"]:
|
|
133
|
+
result["highlights"].append(h["text"])
|
|
134
|
+
except Exception:
|
|
135
|
+
pass
|
|
136
|
+
|
|
137
|
+
# OCR for handwritten content (optional)
|
|
138
|
+
if include_ocr and rm_files:
|
|
139
|
+
result["handwritten_text"] = extract_handwriting_ocr(rm_files)
|
|
140
|
+
|
|
141
|
+
return result
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def extract_handwriting_ocr(rm_files: List[Path]) -> Optional[List[str]]:
|
|
145
|
+
"""
|
|
146
|
+
Extract handwritten text using OCR.
|
|
147
|
+
Requires optional OCR dependencies (pytesseract, rmc).
|
|
148
|
+
"""
|
|
149
|
+
try:
|
|
150
|
+
import subprocess
|
|
151
|
+
|
|
152
|
+
import pytesseract
|
|
153
|
+
from PIL import Image
|
|
154
|
+
|
|
155
|
+
ocr_results = []
|
|
156
|
+
|
|
157
|
+
for rm_file in rm_files:
|
|
158
|
+
# Use rmc to convert .rm to PNG
|
|
159
|
+
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp_png:
|
|
160
|
+
try:
|
|
161
|
+
result = subprocess.run(
|
|
162
|
+
["rmc", "-t", "png", "-o", tmp_png.name, str(rm_file)],
|
|
163
|
+
capture_output=True,
|
|
164
|
+
timeout=30,
|
|
165
|
+
)
|
|
166
|
+
if result.returncode == 0:
|
|
167
|
+
img = Image.open(tmp_png.name)
|
|
168
|
+
text = pytesseract.image_to_string(img)
|
|
169
|
+
if text.strip():
|
|
170
|
+
ocr_results.append(text.strip())
|
|
171
|
+
except subprocess.TimeoutExpired:
|
|
172
|
+
pass
|
|
173
|
+
except FileNotFoundError:
|
|
174
|
+
# rmc not installed
|
|
175
|
+
return None
|
|
176
|
+
finally:
|
|
177
|
+
Path(tmp_png.name).unlink(missing_ok=True)
|
|
178
|
+
|
|
179
|
+
return ocr_results if ocr_results else None
|
|
180
|
+
|
|
181
|
+
except ImportError:
|
|
182
|
+
# OCR dependencies not installed
|
|
183
|
+
return None
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MCP Prompts for reMarkable tablet workflows.
|
|
3
|
+
|
|
4
|
+
Pre-built prompts to help users get started with common tasks.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from remarkable_mcp.server import mcp
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@mcp.prompt(
|
|
11
|
+
name="summarize_recent",
|
|
12
|
+
title="Summarize Recent Notes",
|
|
13
|
+
description="Get an AI summary of your recent reMarkable notes",
|
|
14
|
+
)
|
|
15
|
+
def summarize_recent_prompt() -> list:
|
|
16
|
+
"""Prompt to summarize recent documents."""
|
|
17
|
+
return [
|
|
18
|
+
{
|
|
19
|
+
"role": "user",
|
|
20
|
+
"content": (
|
|
21
|
+
"Please check my recent reMarkable documents using remarkable_recent() "
|
|
22
|
+
"and provide a summary of what I've been working on. "
|
|
23
|
+
"For any documents that look interesting, read their content "
|
|
24
|
+
"with remarkable_read() and give me key highlights."
|
|
25
|
+
),
|
|
26
|
+
}
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@mcp.prompt(
|
|
31
|
+
name="find_notes",
|
|
32
|
+
title="Find Notes About a Topic",
|
|
33
|
+
description="Search your reMarkable tablet for notes on a specific topic",
|
|
34
|
+
)
|
|
35
|
+
def find_notes_prompt(topic: str) -> list:
|
|
36
|
+
"""Prompt to find notes about a topic."""
|
|
37
|
+
return [
|
|
38
|
+
{
|
|
39
|
+
"role": "user",
|
|
40
|
+
"content": (
|
|
41
|
+
f"Search my reMarkable tablet for any notes about '{topic}'. "
|
|
42
|
+
f"Use remarkable_browse(query='{topic}') to find relevant documents, "
|
|
43
|
+
"then use remarkable_read() to extract and summarize the content. "
|
|
44
|
+
"Please organize the information you find."
|
|
45
|
+
),
|
|
46
|
+
}
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@mcp.prompt(
|
|
51
|
+
name="daily_review",
|
|
52
|
+
title="Daily Notes Review",
|
|
53
|
+
description="Review what you worked on today in your reMarkable tablet",
|
|
54
|
+
)
|
|
55
|
+
def daily_review_prompt() -> list:
|
|
56
|
+
"""Prompt for daily review of notes."""
|
|
57
|
+
return [
|
|
58
|
+
{
|
|
59
|
+
"role": "user",
|
|
60
|
+
"content": (
|
|
61
|
+
"Please do a daily review of my reMarkable notes:\n\n"
|
|
62
|
+
"1. Use remarkable_recent(limit=5, include_preview=True) to see "
|
|
63
|
+
"what I worked on recently\n"
|
|
64
|
+
"2. For documents modified today, read the full content\n"
|
|
65
|
+
"3. Summarize the key points and any action items\n"
|
|
66
|
+
"4. Suggest any follow-up tasks based on my notes"
|
|
67
|
+
),
|
|
68
|
+
}
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@mcp.prompt(
|
|
73
|
+
name="export_document",
|
|
74
|
+
title="Export Document Content",
|
|
75
|
+
description="Extract and format content from a specific document",
|
|
76
|
+
)
|
|
77
|
+
def export_document_prompt(document_name: str) -> list:
|
|
78
|
+
"""Prompt to export a specific document."""
|
|
79
|
+
return [
|
|
80
|
+
{
|
|
81
|
+
"role": "user",
|
|
82
|
+
"content": (
|
|
83
|
+
f"Please extract all the content from my reMarkable document "
|
|
84
|
+
f"'{document_name}' using remarkable_read('{document_name}'). "
|
|
85
|
+
"Then format it nicely as markdown, preserving the structure "
|
|
86
|
+
"and any important formatting."
|
|
87
|
+
),
|
|
88
|
+
}
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@mcp.prompt(
|
|
93
|
+
name="organize_library",
|
|
94
|
+
title="Organize My Library",
|
|
95
|
+
description="Get suggestions for organizing your reMarkable library",
|
|
96
|
+
)
|
|
97
|
+
def organize_library_prompt() -> list:
|
|
98
|
+
"""Prompt for library organization suggestions."""
|
|
99
|
+
return [
|
|
100
|
+
{
|
|
101
|
+
"role": "user",
|
|
102
|
+
"content": (
|
|
103
|
+
"Please help me organize my reMarkable library:\n\n"
|
|
104
|
+
"1. Use remarkable_browse('/') to see my current folder structure\n"
|
|
105
|
+
"2. Use remarkable_recent(limit=20) to see my active documents\n"
|
|
106
|
+
"3. Identify any documents that might be misplaced or could be "
|
|
107
|
+
"better organized\n"
|
|
108
|
+
"4. Suggest a folder structure that would help me stay organized\n\n"
|
|
109
|
+
"Note: This server is read-only, so just provide recommendations - "
|
|
110
|
+
"I'll reorganize manually on my tablet."
|
|
111
|
+
),
|
|
112
|
+
}
|
|
113
|
+
]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@mcp.prompt(
|
|
117
|
+
name="meeting_notes",
|
|
118
|
+
title="Extract Meeting Notes",
|
|
119
|
+
description="Find and extract meeting notes from your reMarkable",
|
|
120
|
+
)
|
|
121
|
+
def meeting_notes_prompt(meeting_keyword: str = "meeting") -> list:
|
|
122
|
+
"""Prompt to find and extract meeting notes."""
|
|
123
|
+
return [
|
|
124
|
+
{
|
|
125
|
+
"role": "user",
|
|
126
|
+
"content": (
|
|
127
|
+
f"Find all my meeting notes on my reMarkable tablet:\n\n"
|
|
128
|
+
f"1. Search for documents with remarkable_browse(query='{meeting_keyword}')\n"
|
|
129
|
+
"2. Read the content of each meeting notes document\n"
|
|
130
|
+
"3. Extract key decisions, action items, and attendees mentioned\n"
|
|
131
|
+
"4. Create a consolidated summary of all meetings"
|
|
132
|
+
),
|
|
133
|
+
}
|
|
134
|
+
]
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MCP Resources for reMarkable tablet access.
|
|
3
|
+
|
|
4
|
+
Dynamically registers recent documents as resources on startup.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import tempfile
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from remarkable_mcp.server import mcp
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def register_document_resources():
|
|
18
|
+
"""
|
|
19
|
+
Dynamically register recent documents as MCP resources.
|
|
20
|
+
|
|
21
|
+
Called on startup if API connection exists. Each recent document
|
|
22
|
+
becomes its own resource with URI like remarkable://doc/{name}.
|
|
23
|
+
"""
|
|
24
|
+
try:
|
|
25
|
+
from rmapy.document import Document
|
|
26
|
+
|
|
27
|
+
from remarkable_mcp.api import get_item_path, get_items_by_id, get_rmapi
|
|
28
|
+
from remarkable_mcp.extract import extract_text_from_document_zip
|
|
29
|
+
|
|
30
|
+
client = get_rmapi()
|
|
31
|
+
collection = client.get_meta_items()
|
|
32
|
+
items_by_id = get_items_by_id(collection)
|
|
33
|
+
|
|
34
|
+
# Get documents sorted by modified date
|
|
35
|
+
documents = [item for item in collection if isinstance(item, Document)]
|
|
36
|
+
documents.sort(
|
|
37
|
+
key=lambda x: (
|
|
38
|
+
x.ModifiedClient if hasattr(x, "ModifiedClient") and x.ModifiedClient else ""
|
|
39
|
+
),
|
|
40
|
+
reverse=True,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
# Register each recent document as a resource
|
|
44
|
+
for doc in documents[:10]:
|
|
45
|
+
doc_name = doc.VissibleName
|
|
46
|
+
doc_modified = doc.ModifiedClient if hasattr(doc, "ModifiedClient") else None
|
|
47
|
+
|
|
48
|
+
# Create a closure to capture the document
|
|
49
|
+
def make_resource_fn(document):
|
|
50
|
+
def resource_fn() -> str:
|
|
51
|
+
try:
|
|
52
|
+
raw_doc = client.download(document)
|
|
53
|
+
|
|
54
|
+
with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as tmp:
|
|
55
|
+
tmp.write(raw_doc.content)
|
|
56
|
+
tmp_path = Path(tmp.name)
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
content = extract_text_from_document_zip(tmp_path, include_ocr=False)
|
|
60
|
+
finally:
|
|
61
|
+
tmp_path.unlink(missing_ok=True)
|
|
62
|
+
|
|
63
|
+
# Combine all text content
|
|
64
|
+
text_parts = []
|
|
65
|
+
|
|
66
|
+
if content["typed_text"]:
|
|
67
|
+
text_parts.extend(content["typed_text"])
|
|
68
|
+
|
|
69
|
+
if content["highlights"]:
|
|
70
|
+
text_parts.append("\n--- Highlights ---")
|
|
71
|
+
text_parts.extend(content["highlights"])
|
|
72
|
+
|
|
73
|
+
return "\n\n".join(text_parts) if text_parts else "(No text content found)"
|
|
74
|
+
|
|
75
|
+
except Exception as e:
|
|
76
|
+
return f"Error reading document: {e}"
|
|
77
|
+
|
|
78
|
+
return resource_fn
|
|
79
|
+
|
|
80
|
+
# Register the resource
|
|
81
|
+
resource_uri = f"remarkable://doc/{doc_name}"
|
|
82
|
+
description = f"Content from '{doc_name}'"
|
|
83
|
+
if doc_modified:
|
|
84
|
+
description += f" (modified: {doc_modified})"
|
|
85
|
+
|
|
86
|
+
mcp.resource(
|
|
87
|
+
resource_uri,
|
|
88
|
+
name=doc_name,
|
|
89
|
+
description=description,
|
|
90
|
+
mime_type="text/plain",
|
|
91
|
+
)(make_resource_fn(doc))
|
|
92
|
+
|
|
93
|
+
logger.info(f"Registered {min(len(documents), 10)} document resources")
|
|
94
|
+
|
|
95
|
+
# Also register a folder structure resource
|
|
96
|
+
@mcp.resource(
|
|
97
|
+
"remarkable://folders",
|
|
98
|
+
name="Folder Structure",
|
|
99
|
+
description="Your reMarkable folder hierarchy",
|
|
100
|
+
mime_type="application/json",
|
|
101
|
+
)
|
|
102
|
+
def folders_resource() -> str:
|
|
103
|
+
"""Return folder structure as a resource."""
|
|
104
|
+
from rmapy.folder import Folder
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
folders = []
|
|
108
|
+
for item in collection:
|
|
109
|
+
if isinstance(item, Folder):
|
|
110
|
+
folders.append(
|
|
111
|
+
{
|
|
112
|
+
"name": item.VissibleName,
|
|
113
|
+
"path": get_item_path(item, items_by_id),
|
|
114
|
+
"id": item.ID,
|
|
115
|
+
}
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
folders.sort(key=lambda x: x["path"])
|
|
119
|
+
return json.dumps({"folders": folders}, indent=2)
|
|
120
|
+
|
|
121
|
+
except Exception as e:
|
|
122
|
+
return json.dumps({"error": str(e)})
|
|
123
|
+
|
|
124
|
+
except Exception as e:
|
|
125
|
+
logger.warning(f"Could not register document resources: {e}")
|
|
126
|
+
logger.info("Resources will be available after authentication via remarkable_status()")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# Register resources on module load (if API is available)
|
|
130
|
+
register_document_resources()
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Response helpers for MCP tools.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any, Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def make_response(data: Dict[str, Any], hint: str) -> str:
|
|
10
|
+
"""Create a JSON response with a hint for the model."""
|
|
11
|
+
data["_hint"] = hint
|
|
12
|
+
return json.dumps(data, indent=2)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def make_error(
|
|
16
|
+
error_type: str, message: str, suggestion: str, did_you_mean: Optional[List[str]] = None
|
|
17
|
+
) -> str:
|
|
18
|
+
"""Create an educational error response."""
|
|
19
|
+
error: Dict[str, Any] = {
|
|
20
|
+
"_error": {"type": error_type, "message": message, "suggestion": suggestion}
|
|
21
|
+
}
|
|
22
|
+
if did_you_mean:
|
|
23
|
+
error["_error"]["did_you_mean"] = did_you_mean
|
|
24
|
+
return json.dumps(error, indent=2)
|
remarkable_mcp/server.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""
|
|
2
|
+
reMarkable MCP Server initialization.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from mcp.server.fastmcp import FastMCP
|
|
6
|
+
|
|
7
|
+
# Initialize FastMCP server
|
|
8
|
+
mcp = FastMCP("remarkable")
|
|
9
|
+
|
|
10
|
+
# Import tools, resources, and prompts to register them
|
|
11
|
+
from remarkable_mcp import ( # noqa: E402
|
|
12
|
+
prompts, # noqa: F401
|
|
13
|
+
resources, # noqa: F401
|
|
14
|
+
tools, # noqa: F401
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def run():
|
|
19
|
+
"""Run the MCP server."""
|
|
20
|
+
mcp.run()
|
remarkable_mcp/tools.py
ADDED
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MCP Tools for reMarkable tablet access.
|
|
3
|
+
|
|
4
|
+
All tools are read-only and idempotent - they only retrieve data from the
|
|
5
|
+
reMarkable Cloud and do not modify any documents.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import tempfile
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
from mcp.types import ToolAnnotations
|
|
13
|
+
|
|
14
|
+
from remarkable_mcp.api import (
|
|
15
|
+
REMARKABLE_TOKEN,
|
|
16
|
+
get_item_path,
|
|
17
|
+
get_items_by_id,
|
|
18
|
+
get_items_by_parent,
|
|
19
|
+
get_rmapi,
|
|
20
|
+
)
|
|
21
|
+
from remarkable_mcp.extract import (
|
|
22
|
+
extract_text_from_document_zip,
|
|
23
|
+
find_similar_documents,
|
|
24
|
+
)
|
|
25
|
+
from remarkable_mcp.responses import make_error, make_response
|
|
26
|
+
from remarkable_mcp.server import mcp
|
|
27
|
+
|
|
28
|
+
# Tool annotations for read-only operations
|
|
29
|
+
READ_ONLY_ANNOTATIONS = ToolAnnotations(
|
|
30
|
+
title="Read-only reMarkable operation",
|
|
31
|
+
readOnlyHint=True,
|
|
32
|
+
destructiveHint=False,
|
|
33
|
+
idempotentHint=True,
|
|
34
|
+
openWorldHint=False, # Private cloud account, not open world
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@mcp.tool(annotations=READ_ONLY_ANNOTATIONS)
|
|
39
|
+
def remarkable_read(document: str, include_ocr: bool = False) -> str:
|
|
40
|
+
"""
|
|
41
|
+
<usecase>Read and extract text content from a reMarkable document.</usecase>
|
|
42
|
+
<instructions>
|
|
43
|
+
Extracts all readable text from a document:
|
|
44
|
+
- Typed text from Type Folio or on-screen keyboard (automatic)
|
|
45
|
+
- PDF/EPUB highlights and annotations (automatic)
|
|
46
|
+
- Handwritten text via OCR (if include_ocr=True, slower)
|
|
47
|
+
|
|
48
|
+
This is the recommended tool for getting document content.
|
|
49
|
+
</instructions>
|
|
50
|
+
<parameters>
|
|
51
|
+
- document: Document name or path (use remarkable_browse to find documents)
|
|
52
|
+
- include_ocr: Enable handwriting OCR (default: False, requires OCR extras)
|
|
53
|
+
</parameters>
|
|
54
|
+
<examples>
|
|
55
|
+
- remarkable_read("Meeting Notes")
|
|
56
|
+
- remarkable_read("Work/Project Plan", include_ocr=True)
|
|
57
|
+
</examples>
|
|
58
|
+
"""
|
|
59
|
+
try:
|
|
60
|
+
from rmapy.document import Document
|
|
61
|
+
|
|
62
|
+
client = get_rmapi()
|
|
63
|
+
collection = client.get_meta_items()
|
|
64
|
+
items_by_id = get_items_by_id(collection)
|
|
65
|
+
|
|
66
|
+
# Find the document by name
|
|
67
|
+
documents = [item for item in collection if isinstance(item, Document)]
|
|
68
|
+
target_doc = None
|
|
69
|
+
|
|
70
|
+
for doc in documents:
|
|
71
|
+
if doc.VissibleName == document:
|
|
72
|
+
target_doc = doc
|
|
73
|
+
break
|
|
74
|
+
|
|
75
|
+
if not target_doc:
|
|
76
|
+
# Find similar documents for suggestion
|
|
77
|
+
similar = find_similar_documents(document, documents)
|
|
78
|
+
search_term = document.split()[0] if document else "notes"
|
|
79
|
+
return make_error(
|
|
80
|
+
error_type="document_not_found",
|
|
81
|
+
message=f"Document not found: '{document}'",
|
|
82
|
+
suggestion=(
|
|
83
|
+
f"Try remarkable_browse(query='{search_term}') to search, "
|
|
84
|
+
"or remarkable_browse('/') to list all files."
|
|
85
|
+
),
|
|
86
|
+
did_you_mean=similar if similar else None,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# Download the document
|
|
90
|
+
raw_doc = client.download(target_doc)
|
|
91
|
+
|
|
92
|
+
with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as tmp:
|
|
93
|
+
tmp.write(raw_doc.content)
|
|
94
|
+
tmp_path = Path(tmp.name)
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
# Extract text content
|
|
98
|
+
content = extract_text_from_document_zip(tmp_path, include_ocr=include_ocr)
|
|
99
|
+
finally:
|
|
100
|
+
tmp_path.unlink(missing_ok=True)
|
|
101
|
+
|
|
102
|
+
doc_path = get_item_path(target_doc, items_by_id)
|
|
103
|
+
|
|
104
|
+
result = {
|
|
105
|
+
"document": target_doc.VissibleName,
|
|
106
|
+
"path": doc_path,
|
|
107
|
+
"content": {
|
|
108
|
+
"typed_text": content["typed_text"],
|
|
109
|
+
"highlights": content["highlights"],
|
|
110
|
+
"handwritten_text": content["handwritten_text"],
|
|
111
|
+
},
|
|
112
|
+
"pages": content["pages"],
|
|
113
|
+
"modified": (
|
|
114
|
+
target_doc.ModifiedClient if hasattr(target_doc, "ModifiedClient") else None
|
|
115
|
+
),
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
# Build contextual hint
|
|
119
|
+
folder_path = "/".join(doc_path.split("/")[:-1]) or "/"
|
|
120
|
+
hint_parts = ["Text extracted."]
|
|
121
|
+
|
|
122
|
+
if content["typed_text"]:
|
|
123
|
+
hint_parts.append(f"Found {len(content['typed_text'])} text segments.")
|
|
124
|
+
else:
|
|
125
|
+
hint_parts.append("No typed text found.")
|
|
126
|
+
if not include_ocr:
|
|
127
|
+
hint_parts.append("Try include_ocr=True for handwritten content.")
|
|
128
|
+
|
|
129
|
+
hint_parts.append(f"To see other files: remarkable_browse('{folder_path}').")
|
|
130
|
+
|
|
131
|
+
return make_response(result, " ".join(hint_parts))
|
|
132
|
+
|
|
133
|
+
except Exception as e:
|
|
134
|
+
return make_error(
|
|
135
|
+
error_type="read_failed",
|
|
136
|
+
message=str(e),
|
|
137
|
+
suggestion="Check remarkable_status() to verify your connection.",
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@mcp.tool(annotations=READ_ONLY_ANNOTATIONS)
|
|
142
|
+
def remarkable_browse(path: str = "/", query: Optional[str] = None) -> str:
|
|
143
|
+
"""
|
|
144
|
+
<usecase>Browse your reMarkable library or search for documents.</usecase>
|
|
145
|
+
<instructions>
|
|
146
|
+
Two modes:
|
|
147
|
+
1. Browse mode (default): List contents of a folder
|
|
148
|
+
- Use path="/" for root folder
|
|
149
|
+
- Use path="/FolderName" to navigate into folders
|
|
150
|
+
2. Search mode: Find documents by name
|
|
151
|
+
- Set query="search term" to search across all documents
|
|
152
|
+
|
|
153
|
+
Results include document names, types, and modification dates.
|
|
154
|
+
</instructions>
|
|
155
|
+
<parameters>
|
|
156
|
+
- path: Folder path to browse (default: "/" for root)
|
|
157
|
+
- query: Search term to find documents by name (optional, triggers search mode)
|
|
158
|
+
</parameters>
|
|
159
|
+
<examples>
|
|
160
|
+
- remarkable_browse() # List root folder
|
|
161
|
+
- remarkable_browse("/Work") # List Work folder
|
|
162
|
+
- remarkable_browse(query="meeting") # Search for "meeting"
|
|
163
|
+
</examples>
|
|
164
|
+
"""
|
|
165
|
+
try:
|
|
166
|
+
from rmapy.document import Document
|
|
167
|
+
from rmapy.folder import Folder
|
|
168
|
+
|
|
169
|
+
client = get_rmapi()
|
|
170
|
+
collection = client.get_meta_items()
|
|
171
|
+
items_by_id = get_items_by_id(collection)
|
|
172
|
+
items_by_parent = get_items_by_parent(collection)
|
|
173
|
+
|
|
174
|
+
# Search mode
|
|
175
|
+
if query:
|
|
176
|
+
query_lower = query.lower()
|
|
177
|
+
matches = []
|
|
178
|
+
|
|
179
|
+
for item in collection:
|
|
180
|
+
if query_lower in item.VissibleName.lower():
|
|
181
|
+
matches.append(
|
|
182
|
+
{
|
|
183
|
+
"name": item.VissibleName,
|
|
184
|
+
"path": get_item_path(item, items_by_id),
|
|
185
|
+
"type": "folder" if isinstance(item, Folder) else "document",
|
|
186
|
+
"modified": (
|
|
187
|
+
item.ModifiedClient if hasattr(item, "ModifiedClient") else None
|
|
188
|
+
),
|
|
189
|
+
}
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
matches.sort(key=lambda x: x["name"])
|
|
193
|
+
|
|
194
|
+
result = {"mode": "search", "query": query, "count": len(matches), "results": matches}
|
|
195
|
+
|
|
196
|
+
if matches:
|
|
197
|
+
first_doc = next((m for m in matches if m["type"] == "document"), None)
|
|
198
|
+
if first_doc:
|
|
199
|
+
hint = (
|
|
200
|
+
f"Found {len(matches)} results. "
|
|
201
|
+
f"To read a document: remarkable_read('{first_doc['name']}')."
|
|
202
|
+
)
|
|
203
|
+
else:
|
|
204
|
+
hint = (
|
|
205
|
+
f"Found {len(matches)} folders. "
|
|
206
|
+
f"To browse one: remarkable_browse('{matches[0]['path']}')."
|
|
207
|
+
)
|
|
208
|
+
else:
|
|
209
|
+
hint = (
|
|
210
|
+
f"No results for '{query}'. "
|
|
211
|
+
"Try remarkable_browse('/') to see all files, "
|
|
212
|
+
"or use a different search term."
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
return make_response(result, hint)
|
|
216
|
+
|
|
217
|
+
# Browse mode
|
|
218
|
+
if path == "/" or path == "":
|
|
219
|
+
target_parent = ""
|
|
220
|
+
else:
|
|
221
|
+
# Navigate to the folder
|
|
222
|
+
path_parts = [p for p in path.strip("/").split("/") if p]
|
|
223
|
+
current_parent = ""
|
|
224
|
+
|
|
225
|
+
for part in path_parts:
|
|
226
|
+
found = False
|
|
227
|
+
for item in items_by_parent.get(current_parent, []):
|
|
228
|
+
if item.VissibleName == part and isinstance(item, Folder):
|
|
229
|
+
current_parent = item.ID
|
|
230
|
+
found = True
|
|
231
|
+
break
|
|
232
|
+
|
|
233
|
+
if not found:
|
|
234
|
+
# Folder not found - suggest alternatives
|
|
235
|
+
available_folders = [
|
|
236
|
+
item.VissibleName
|
|
237
|
+
for item in items_by_parent.get(current_parent, [])
|
|
238
|
+
if isinstance(item, Folder)
|
|
239
|
+
]
|
|
240
|
+
return make_error(
|
|
241
|
+
error_type="folder_not_found",
|
|
242
|
+
message=f"Folder not found: '{part}'",
|
|
243
|
+
suggestion=("Use remarkable_browse('/') to see root folder contents."),
|
|
244
|
+
did_you_mean=(available_folders[:5] if available_folders else None),
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
target_parent = current_parent
|
|
248
|
+
|
|
249
|
+
items = items_by_parent.get(target_parent, [])
|
|
250
|
+
|
|
251
|
+
folders = []
|
|
252
|
+
documents = []
|
|
253
|
+
|
|
254
|
+
for item in sorted(items, key=lambda x: x.VissibleName.lower()):
|
|
255
|
+
if isinstance(item, Folder):
|
|
256
|
+
folders.append({"name": item.VissibleName, "id": item.ID})
|
|
257
|
+
elif isinstance(item, Document):
|
|
258
|
+
documents.append(
|
|
259
|
+
{
|
|
260
|
+
"name": item.VissibleName,
|
|
261
|
+
"id": item.ID,
|
|
262
|
+
"modified": (
|
|
263
|
+
item.ModifiedClient if hasattr(item, "ModifiedClient") else None
|
|
264
|
+
),
|
|
265
|
+
}
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
result = {"mode": "browse", "path": path, "folders": folders, "documents": documents}
|
|
269
|
+
|
|
270
|
+
# Build helpful hint
|
|
271
|
+
hint_parts = [f"Found {len(folders)} folder(s) and {len(documents)} document(s)."]
|
|
272
|
+
|
|
273
|
+
if documents:
|
|
274
|
+
hint_parts.append(f"To read a document: remarkable_read('{documents[0]['name']}').")
|
|
275
|
+
if folders:
|
|
276
|
+
folder_path = f"{path.rstrip('/')}/{folders[0]['name']}"
|
|
277
|
+
hint_parts.append(f"To enter a folder: remarkable_browse('{folder_path}').")
|
|
278
|
+
if not folders and not documents:
|
|
279
|
+
hint_parts.append("This folder is empty.")
|
|
280
|
+
|
|
281
|
+
return make_response(result, " ".join(hint_parts))
|
|
282
|
+
|
|
283
|
+
except Exception as e:
|
|
284
|
+
return make_error(
|
|
285
|
+
error_type="browse_failed",
|
|
286
|
+
message=str(e),
|
|
287
|
+
suggestion="Check remarkable_status() to verify your connection.",
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
@mcp.tool(annotations=READ_ONLY_ANNOTATIONS)
|
|
292
|
+
def remarkable_recent(limit: int = 10, include_preview: bool = False) -> str:
|
|
293
|
+
"""
|
|
294
|
+
<usecase>Get your most recently modified documents.</usecase>
|
|
295
|
+
<instructions>
|
|
296
|
+
Returns documents sorted by modification date (newest first).
|
|
297
|
+
Optionally includes a text preview of each document's content.
|
|
298
|
+
|
|
299
|
+
Use this to quickly find what you were working on recently.
|
|
300
|
+
</instructions>
|
|
301
|
+
<parameters>
|
|
302
|
+
- limit: Maximum documents to return (default: 10, max: 50)
|
|
303
|
+
- include_preview: Include first ~200 chars of text content (default: False)
|
|
304
|
+
</parameters>
|
|
305
|
+
<examples>
|
|
306
|
+
- remarkable_recent() # Last 10 documents
|
|
307
|
+
- remarkable_recent(limit=5, include_preview=True) # With content preview
|
|
308
|
+
</examples>
|
|
309
|
+
"""
|
|
310
|
+
try:
|
|
311
|
+
from rmapy.document import Document
|
|
312
|
+
|
|
313
|
+
client = get_rmapi()
|
|
314
|
+
collection = client.get_meta_items()
|
|
315
|
+
items_by_id = get_items_by_id(collection)
|
|
316
|
+
|
|
317
|
+
# Clamp limit
|
|
318
|
+
limit = min(max(1, limit), 50)
|
|
319
|
+
|
|
320
|
+
# Get documents sorted by modified date
|
|
321
|
+
documents = [item for item in collection if isinstance(item, Document)]
|
|
322
|
+
documents.sort(
|
|
323
|
+
key=lambda x: (
|
|
324
|
+
x.ModifiedClient if hasattr(x, "ModifiedClient") and x.ModifiedClient else ""
|
|
325
|
+
),
|
|
326
|
+
reverse=True,
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
results = []
|
|
330
|
+
for doc in documents[:limit]:
|
|
331
|
+
doc_info = {
|
|
332
|
+
"name": doc.VissibleName,
|
|
333
|
+
"path": get_item_path(doc, items_by_id),
|
|
334
|
+
"modified": (doc.ModifiedClient if hasattr(doc, "ModifiedClient") else None),
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if include_preview:
|
|
338
|
+
# Download and extract preview
|
|
339
|
+
try:
|
|
340
|
+
raw_doc = client.download(doc)
|
|
341
|
+
with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as tmp:
|
|
342
|
+
tmp.write(raw_doc.content)
|
|
343
|
+
tmp_path = Path(tmp.name)
|
|
344
|
+
|
|
345
|
+
try:
|
|
346
|
+
content = extract_text_from_document_zip(tmp_path, include_ocr=False)
|
|
347
|
+
preview_text = "\n".join(content["typed_text"])[:200]
|
|
348
|
+
if len(preview_text) == 200:
|
|
349
|
+
doc_info["preview"] = preview_text + "..."
|
|
350
|
+
else:
|
|
351
|
+
doc_info["preview"] = preview_text
|
|
352
|
+
finally:
|
|
353
|
+
tmp_path.unlink(missing_ok=True)
|
|
354
|
+
except Exception:
|
|
355
|
+
doc_info["preview"] = None
|
|
356
|
+
|
|
357
|
+
results.append(doc_info)
|
|
358
|
+
|
|
359
|
+
result = {"count": len(results), "documents": results}
|
|
360
|
+
|
|
361
|
+
if results:
|
|
362
|
+
next_limit = min(limit * 2, 50)
|
|
363
|
+
hint = (
|
|
364
|
+
f"Showing {len(results)} recent documents. "
|
|
365
|
+
f"To read one: remarkable_read('{results[0]['name']}'). "
|
|
366
|
+
f"To see more: remarkable_recent(limit={next_limit})."
|
|
367
|
+
)
|
|
368
|
+
else:
|
|
369
|
+
hint = "No documents found. Use remarkable_browse('/') to check your library."
|
|
370
|
+
|
|
371
|
+
return make_response(result, hint)
|
|
372
|
+
|
|
373
|
+
except Exception as e:
|
|
374
|
+
return make_error(
|
|
375
|
+
error_type="recent_failed",
|
|
376
|
+
message=str(e),
|
|
377
|
+
suggestion="Check remarkable_status() to verify your connection.",
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
@mcp.tool(annotations=READ_ONLY_ANNOTATIONS)
|
|
382
|
+
def remarkable_status() -> str:
|
|
383
|
+
"""
|
|
384
|
+
<usecase>Check connection status and authentication with reMarkable Cloud.</usecase>
|
|
385
|
+
<instructions>
|
|
386
|
+
Returns authentication status and diagnostic information.
|
|
387
|
+
Use this to verify your connection or troubleshoot issues.
|
|
388
|
+
</instructions>
|
|
389
|
+
<examples>
|
|
390
|
+
- remarkable_status()
|
|
391
|
+
</examples>
|
|
392
|
+
"""
|
|
393
|
+
token_source = "environment variable" if REMARKABLE_TOKEN else "file (~/.rmapi)"
|
|
394
|
+
|
|
395
|
+
try:
|
|
396
|
+
from rmapy.document import Document
|
|
397
|
+
|
|
398
|
+
client = get_rmapi()
|
|
399
|
+
collection = client.get_meta_items()
|
|
400
|
+
|
|
401
|
+
doc_count = sum(1 for item in collection if isinstance(item, Document))
|
|
402
|
+
|
|
403
|
+
result = {
|
|
404
|
+
"authenticated": True,
|
|
405
|
+
"token_source": token_source,
|
|
406
|
+
"cloud_status": "connected",
|
|
407
|
+
"document_count": doc_count,
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return make_response(
|
|
411
|
+
result,
|
|
412
|
+
(
|
|
413
|
+
f"Connected successfully. Found {doc_count} documents. "
|
|
414
|
+
"Use remarkable_browse() to see your files, "
|
|
415
|
+
"or remarkable_recent() for recent documents."
|
|
416
|
+
),
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
except Exception as e:
|
|
420
|
+
error_msg = str(e)
|
|
421
|
+
|
|
422
|
+
result = {"authenticated": False, "error": error_msg, "token_source": token_source}
|
|
423
|
+
|
|
424
|
+
hint = (
|
|
425
|
+
"To authenticate: "
|
|
426
|
+
"1) Go to https://my.remarkable.com/device/browser/connect "
|
|
427
|
+
"2) Get a one-time code "
|
|
428
|
+
"3) Run: uv run python server.py --register YOUR_CODE "
|
|
429
|
+
"4) Add REMARKABLE_TOKEN to your MCP config."
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
return make_response(result, hint)
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: remarkable-mcp
|
|
3
|
+
Version: 0.1.3
|
|
4
|
+
Summary: MCP server for accessing reMarkable tablet data
|
|
5
|
+
Author: Sam Morrow
|
|
6
|
+
License: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: mcp,notes,remarkable,sync,tablet
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Requires-Dist: mcp>=1.0.0
|
|
18
|
+
Requires-Dist: pillow>=10.0.0
|
|
19
|
+
Requires-Dist: pytesseract>=0.3.10
|
|
20
|
+
Requires-Dist: rmapy>=0.3.0
|
|
21
|
+
Requires-Dist: rmc>=0.3.0
|
|
22
|
+
Requires-Dist: rmscene>=0.6.0
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: black>=23.0.0; extra == 'dev'
|
|
25
|
+
Requires-Dist: pytest-asyncio>=1.0.0; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest>=7.0.0; extra == 'dev'
|
|
27
|
+
Requires-Dist: ruff>=0.1.0; extra == 'dev'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# reMarkable MCP Server
|
|
31
|
+
|
|
32
|
+
An [MCP (Model Context Protocol)](https://modelcontextprotocol.io/) server for accessing your reMarkable tablet data through the reMarkable Cloud.
|
|
33
|
+
|
|
34
|
+
<!-- mcp-name: io.github.sammorrowdrums/remarkable -->
|
|
35
|
+
|
|
36
|
+
## Quick Install
|
|
37
|
+
|
|
38
|
+
[](https://insiders.vscode.dev/redirect/mcp/install?name=remarkable&inputs=%5B%7B%22type%22%3A%22promptString%22%2C%22id%22%3A%22token%22%2C%22description%22%3A%22reMarkable%20API%20token%20(run%20uvx%20remarkable-mcp%20--register%20CODE%20to%20get%20one)%22%2C%22password%22%3Atrue%7D%5D&config=%7B%22command%22%3A%22uvx%22%2C%22args%22%3A%5B%22remarkable-mcp%22%5D%2C%22env%22%3A%7B%22REMARKABLE_TOKEN%22%3A%22%24%7Binput%3Atoken%7D%22%7D%7D) [](https://insiders.vscode.dev/redirect/mcp/install?name=remarkable&inputs=%5B%7B%22type%22%3A%22promptString%22%2C%22id%22%3A%22token%22%2C%22description%22%3A%22reMarkable%20API%20token%20(run%20uvx%20remarkable-mcp%20--register%20CODE%20to%20get%20one)%22%2C%22password%22%3Atrue%7D%5D&config=%7B%22command%22%3A%22uvx%22%2C%22args%22%3A%5B%22remarkable-mcp%22%5D%2C%22env%22%3A%7B%22REMARKABLE_TOKEN%22%3A%22%24%7Binput%3Atoken%7D%22%7D%7D&quality=insiders)
|
|
39
|
+
|
|
40
|
+
## What It Does
|
|
41
|
+
|
|
42
|
+
- Read typed text directly from notebooks (v3+ software, no OCR needed)
|
|
43
|
+
- Browse and search your document library
|
|
44
|
+
- Access recent files with content previews
|
|
45
|
+
- OCR for handwritten content via pytesseract
|
|
46
|
+
- MCP resources and prompts for deeper integration
|
|
47
|
+
|
|
48
|
+
## Installation
|
|
49
|
+
|
|
50
|
+
### Using uvx (Recommended)
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
# Get your reMarkable token
|
|
54
|
+
uvx remarkable-mcp --register YOUR_ONE_TIME_CODE
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Click the **Quick Install** badges above, or configure manually.
|
|
58
|
+
|
|
59
|
+
### From Source
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
git clone https://github.com/SamMorrowDrums/remarkable-mcp.git
|
|
63
|
+
cd remarkable-mcp
|
|
64
|
+
uv sync
|
|
65
|
+
uv run python server.py --register YOUR_ONE_TIME_CODE
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Setup
|
|
69
|
+
|
|
70
|
+
### 1. Get a One-Time Code
|
|
71
|
+
|
|
72
|
+
Go to [my.remarkable.com/device/desktop/connect](https://my.remarkable.com/device/desktop/connect) and generate a code.
|
|
73
|
+
|
|
74
|
+
### 2. Convert to Token
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
uvx remarkable-mcp --register YOUR_CODE
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### 3. Configure MCP
|
|
81
|
+
|
|
82
|
+
**VS Code** — Add to `.vscode/mcp.json`:
|
|
83
|
+
|
|
84
|
+
```json
|
|
85
|
+
{
|
|
86
|
+
"inputs": [
|
|
87
|
+
{
|
|
88
|
+
"type": "promptString",
|
|
89
|
+
"id": "remarkable-token",
|
|
90
|
+
"description": "reMarkable API Token",
|
|
91
|
+
"password": true
|
|
92
|
+
}
|
|
93
|
+
],
|
|
94
|
+
"servers": {
|
|
95
|
+
"remarkable": {
|
|
96
|
+
"command": "uvx",
|
|
97
|
+
"args": ["remarkable-mcp"],
|
|
98
|
+
"env": {
|
|
99
|
+
"REMARKABLE_TOKEN": "${input:remarkable-token}"
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Your token is stored securely using VS Code's input system with `password: true`.
|
|
107
|
+
|
|
108
|
+
**Claude Desktop** — Add to `claude_desktop_config.json`:
|
|
109
|
+
|
|
110
|
+
```json
|
|
111
|
+
{
|
|
112
|
+
"mcpServers": {
|
|
113
|
+
"remarkable": {
|
|
114
|
+
"command": "uvx",
|
|
115
|
+
"args": ["remarkable-mcp"],
|
|
116
|
+
"env": {
|
|
117
|
+
"REMARKABLE_TOKEN": "your-token-from-step-2"
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Tools
|
|
125
|
+
|
|
126
|
+
| Tool | Description |
|
|
127
|
+
|------|-------------|
|
|
128
|
+
| `remarkable_read` | Extract text from a document |
|
|
129
|
+
| `remarkable_browse` | List files or search by name |
|
|
130
|
+
| `remarkable_recent` | Get recently modified documents |
|
|
131
|
+
| `remarkable_status` | Check connection status |
|
|
132
|
+
|
|
133
|
+
All tools are read-only and return structured JSON with hints for next actions.
|
|
134
|
+
|
|
135
|
+
## Resources
|
|
136
|
+
|
|
137
|
+
Recent documents are automatically registered as MCP resources on startup (if authenticated). Each document becomes available at `remarkable://doc/{name}`.
|
|
138
|
+
|
|
139
|
+
| URI | Description |
|
|
140
|
+
|-----|-------------|
|
|
141
|
+
| `remarkable://doc/{name}` | Content of a recent document |
|
|
142
|
+
| `remarkable://folders` | Complete folder hierarchy |
|
|
143
|
+
|
|
144
|
+
## Prompts
|
|
145
|
+
|
|
146
|
+
`summarize_recent` · `find_notes` · `daily_review` · `export_document` · `organize_library` · `meeting_notes`
|
|
147
|
+
|
|
148
|
+
## Usage
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
remarkable_read("Meeting Notes - Nov 2025")
|
|
152
|
+
remarkable_browse("/")
|
|
153
|
+
remarkable_browse(query="meeting")
|
|
154
|
+
remarkable_recent(limit=5, include_preview=True)
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## Text Extraction
|
|
158
|
+
|
|
159
|
+
**Typed text** from v3+ notebooks is extracted natively via `rmscene` — no OCR required.
|
|
160
|
+
|
|
161
|
+
**Handwritten content** uses pytesseract for OCR. Make sure Tesseract is installed on your system:
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
# macOS
|
|
165
|
+
brew install tesseract
|
|
166
|
+
|
|
167
|
+
# Ubuntu/Debian
|
|
168
|
+
sudo apt-get install tesseract-ocr
|
|
169
|
+
|
|
170
|
+
# Arch
|
|
171
|
+
sudo pacman -S tesseract
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
PDF highlights and annotations are also extracted.
|
|
175
|
+
|
|
176
|
+
## Design
|
|
177
|
+
|
|
178
|
+
Intent-based tools that map to what you actually want to do. Responses include hints for logical next steps. Errors explain what went wrong and how to fix it. Four tools cover most use cases.
|
|
179
|
+
|
|
180
|
+
## Authentication
|
|
181
|
+
|
|
182
|
+
Set `REMARKABLE_TOKEN` in your MCP config (recommended), or the server will fall back to `~/.rmapi`.
|
|
183
|
+
|
|
184
|
+
## Development
|
|
185
|
+
|
|
186
|
+
```bash
|
|
187
|
+
uv sync --all-extras
|
|
188
|
+
uv run pytest test_server.py -v
|
|
189
|
+
uv run ruff check .
|
|
190
|
+
uv run ruff format .
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
### Project Structure
|
|
194
|
+
|
|
195
|
+
```
|
|
196
|
+
remarkable-mcp/
|
|
197
|
+
├── server.py # Entry point
|
|
198
|
+
├── remarkable_mcp/ # Main package
|
|
199
|
+
│ ├── server.py # FastMCP server
|
|
200
|
+
│ ├── api.py # Cloud API helpers
|
|
201
|
+
│ ├── extract.py # Text extraction
|
|
202
|
+
│ ├── tools.py # MCP tools
|
|
203
|
+
│ ├── resources.py # MCP resources
|
|
204
|
+
│ └── prompts.py # MCP prompts
|
|
205
|
+
└── test_server.py # Tests
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
## License
|
|
209
|
+
|
|
210
|
+
MIT
|
|
211
|
+
|
|
212
|
+
---
|
|
213
|
+
|
|
214
|
+
Built with [rmapy](https://github.com/subutux/rmapy), [rmscene](https://github.com/ricklupton/rmscene), and inspiration from [Scrybble](https://github.com/Scrybbling-together/scrybble).
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
remarkable_mcp/__init__.py,sha256=UMORz2IRP8uSmd19rVdRC3-axmGV7r9orEtep4nCRVo,328
|
|
2
|
+
remarkable_mcp/api.py,sha256=lHgi0oR7q9AaHd5r4x2ZO5pAv5Tg9elE-7KTg6o0QW4,4023
|
|
3
|
+
remarkable_mcp/extract.py,sha256=ymOPy-7wwRufLqjUI68xqOL_0ha3PZhXHHqT-d-XtIQ,6132
|
|
4
|
+
remarkable_mcp/prompts.py,sha256=RaVMbjHeWkHcZCgPbfMO-Dp6uAcU7eYyQ6eHODEhnpk,4572
|
|
5
|
+
remarkable_mcp/resources.py,sha256=Vt9q_wLSfIkFDwz43HQ-tJpOBdW3y5XULihAttAmuXk,4557
|
|
6
|
+
remarkable_mcp/responses.py,sha256=lWzR1S-uyumq1EE7MORTAij7nXL8OWr2Vnd0KcOws3o,687
|
|
7
|
+
remarkable_mcp/server.py,sha256=bVrMGpOGY5rBEaBeQTc1C8FMH6Z0JGSpxpBo8f_pKZc,383
|
|
8
|
+
remarkable_mcp/tools.py,sha256=UI342rftabE310GAPlwF4gs8aFC-J1buVuD7iiT0JEA,15440
|
|
9
|
+
remarkable_mcp-0.1.3.dist-info/METADATA,sha256=36wkRzx_ASOGvBa3WYNLC3nRXY4s5cppY90VE4DkD-E,6598
|
|
10
|
+
remarkable_mcp-0.1.3.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
11
|
+
remarkable_mcp-0.1.3.dist-info/entry_points.txt,sha256=u7Cq0gv3lSJ2gfmxevurtFQdFxzInISM4OWH9Kye13Q,47
|
|
12
|
+
remarkable_mcp-0.1.3.dist-info/licenses/LICENSE,sha256=AFH_vQYJYLgWL0eKYRDSJwbpnvL12LTkSHBWbnKYEt4,1067
|
|
13
|
+
remarkable_mcp-0.1.3.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Sam Morrow
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|