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.
@@ -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)
@@ -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()
@@ -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
+ [![Install with UVX in VS Code](https://img.shields.io/badge/VS_Code-UVX-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](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) [![Install with UVX in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-UVX-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ remarkable-mcp = server:main
@@ -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.