linear-mcp-fast 0.2.6__py3-none-any.whl → 0.3.1__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.
- linear_mcp_fast/reader.py +95 -1
- linear_mcp_fast/store_detector.py +9 -0
- {linear_mcp_fast-0.2.6.dist-info → linear_mcp_fast-0.3.1.dist-info}/METADATA +12 -2
- {linear_mcp_fast-0.2.6.dist-info → linear_mcp_fast-0.3.1.dist-info}/RECORD +7 -7
- {linear_mcp_fast-0.2.6.dist-info → linear_mcp_fast-0.3.1.dist-info}/WHEEL +1 -1
- {linear_mcp_fast-0.2.6.dist-info → linear_mcp_fast-0.3.1.dist-info}/entry_points.txt +0 -0
- {linear_mcp_fast-0.2.6.dist-info → linear_mcp_fast-0.3.1.dist-info}/top_level.txt +0 -0
linear_mcp_fast/reader.py
CHANGED
|
@@ -5,8 +5,10 @@ Reads Linear's local IndexedDB cache to provide fast access to issues, users,
|
|
|
5
5
|
teams, workflow states, and comments without API calls.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
+
import base64
|
|
8
9
|
import json
|
|
9
10
|
import os
|
|
11
|
+
import re
|
|
10
12
|
import time
|
|
11
13
|
from dataclasses import dataclass, field
|
|
12
14
|
from typing import Any
|
|
@@ -36,6 +38,7 @@ class CachedData:
|
|
|
36
38
|
comments: dict[str, dict[str, Any]] = field(default_factory=dict)
|
|
37
39
|
comments_by_issue: dict[str, list[str]] = field(default_factory=dict)
|
|
38
40
|
projects: dict[str, dict[str, Any]] = field(default_factory=dict)
|
|
41
|
+
issue_content: dict[str, str] = field(default_factory=dict) # issueId -> description
|
|
39
42
|
loaded_at: float = 0.0
|
|
40
43
|
|
|
41
44
|
def is_expired(self) -> bool:
|
|
@@ -92,6 +95,77 @@ class LinearLocalReader:
|
|
|
92
95
|
return val.decode("utf-8", errors="replace")
|
|
93
96
|
return str(val)
|
|
94
97
|
|
|
98
|
+
def _extract_yjs_text(self, content_state: str | None) -> str:
|
|
99
|
+
"""Extract readable text from Y.js encoded contentState."""
|
|
100
|
+
if not content_state:
|
|
101
|
+
return ""
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
decoded = base64.b64decode(content_state)
|
|
105
|
+
text = decoded.decode("utf-8", errors="replace")
|
|
106
|
+
|
|
107
|
+
# Extract readable text (Korean + ASCII printable)
|
|
108
|
+
readable = re.findall(r"[\uac00-\ud7af\u0020-\u007e]+", text)
|
|
109
|
+
|
|
110
|
+
# Structural markers to skip (ProseMirror/Y.js)
|
|
111
|
+
skip_exact = {
|
|
112
|
+
"prosemirror", "paragraph", "heading", "bullet_list", "list_item",
|
|
113
|
+
"ordered_list", "level", "link", "null", "strong", "em", "code",
|
|
114
|
+
"table", "table_row", "table_cell", "table_header", "colspan",
|
|
115
|
+
"rowspan", "colwidth", "issuemention", "label", "href", "title",
|
|
116
|
+
"order", "attrs", "content", "marks", "type", "text", "doc",
|
|
117
|
+
"blockquote", "code_block", "hard_break", "horizontal_rule",
|
|
118
|
+
"image", "suggestion_usermentions", "todo_item", "done", "language",
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
result = []
|
|
122
|
+
for r in readable:
|
|
123
|
+
r = r.strip()
|
|
124
|
+
if len(r) < 2:
|
|
125
|
+
continue
|
|
126
|
+
|
|
127
|
+
# Skip exact structural markers
|
|
128
|
+
if r.lower() in skip_exact:
|
|
129
|
+
continue
|
|
130
|
+
|
|
131
|
+
# Skip Y.js IDs and encoded strings
|
|
132
|
+
if re.match(r"^w[\$\)\(A-Z]", r):
|
|
133
|
+
continue
|
|
134
|
+
|
|
135
|
+
# Skip JSON objects and JSON-like patterns
|
|
136
|
+
if r.startswith("{") or '{"' in r:
|
|
137
|
+
continue
|
|
138
|
+
|
|
139
|
+
# Skip link markers with JSON
|
|
140
|
+
if r.startswith("link") and "{" in r:
|
|
141
|
+
continue
|
|
142
|
+
|
|
143
|
+
# Skip UUIDs
|
|
144
|
+
if re.match(r"^[a-f0-9-]{36}$", r):
|
|
145
|
+
continue
|
|
146
|
+
|
|
147
|
+
# Skip single characters or pure numbers
|
|
148
|
+
if len(r) <= 2 and not re.search(r"[\uac00-\ud7af]", r):
|
|
149
|
+
continue
|
|
150
|
+
|
|
151
|
+
# Skip strings that are mostly special characters
|
|
152
|
+
if len(r) > 0:
|
|
153
|
+
special_ratio = sum(1 for c in r if c in "()[]{}$#@*&^%") / len(r)
|
|
154
|
+
if special_ratio > 0.3:
|
|
155
|
+
continue
|
|
156
|
+
|
|
157
|
+
result.append(r)
|
|
158
|
+
|
|
159
|
+
# Join and clean up
|
|
160
|
+
text = " ".join(result)
|
|
161
|
+
# Remove excessive whitespace and parentheses artifacts
|
|
162
|
+
text = re.sub(r"\s*\(\s*$", "", text)
|
|
163
|
+
text = re.sub(r"^\s*\)\s*", "", text)
|
|
164
|
+
text = re.sub(r"\s+", " ", text)
|
|
165
|
+
return text.strip()
|
|
166
|
+
except Exception:
|
|
167
|
+
return ""
|
|
168
|
+
|
|
95
169
|
def _extract_comment_text(self, body_data: Any) -> str:
|
|
96
170
|
"""Extract plain text from ProseMirror bodyData format."""
|
|
97
171
|
if body_data is None:
|
|
@@ -185,11 +259,16 @@ class LinearLocalReader:
|
|
|
185
259
|
team_key = team.get("key", "???")
|
|
186
260
|
identifier = f"{team_key}-{val.get('number')}"
|
|
187
261
|
|
|
262
|
+
# Try descriptionData (ProseMirror format) first, fall back to description
|
|
263
|
+
description = val.get("description")
|
|
264
|
+
if not description and val.get("descriptionData"):
|
|
265
|
+
description = self._extract_comment_text(val.get("descriptionData"))
|
|
266
|
+
|
|
188
267
|
cache.issues[val["id"]] = {
|
|
189
268
|
"id": val["id"],
|
|
190
269
|
"identifier": identifier,
|
|
191
270
|
"title": val.get("title"),
|
|
192
|
-
"description":
|
|
271
|
+
"description": description,
|
|
193
272
|
"number": val.get("number"),
|
|
194
273
|
"priority": val.get("priority"),
|
|
195
274
|
"estimate": val.get("estimate"),
|
|
@@ -246,6 +325,21 @@ class LinearLocalReader:
|
|
|
246
325
|
"updatedAt": val.get("updatedAt"),
|
|
247
326
|
}
|
|
248
327
|
|
|
328
|
+
# Load issue content (Y.js encoded descriptions)
|
|
329
|
+
if self._stores.issue_content:
|
|
330
|
+
for val in self._load_from_store(db, self._stores.issue_content):
|
|
331
|
+
issue_id = val.get("issueId")
|
|
332
|
+
content_state = val.get("contentState")
|
|
333
|
+
if issue_id and content_state:
|
|
334
|
+
extracted = self._extract_yjs_text(content_state)
|
|
335
|
+
if extracted:
|
|
336
|
+
cache.issue_content[issue_id] = extracted
|
|
337
|
+
|
|
338
|
+
# Update issues with descriptions from issue_content
|
|
339
|
+
for issue_id, desc in cache.issue_content.items():
|
|
340
|
+
if issue_id in cache.issues and not cache.issues[issue_id].get("description"):
|
|
341
|
+
cache.issues[issue_id]["description"] = desc
|
|
342
|
+
|
|
249
343
|
self._cache = cache
|
|
250
344
|
|
|
251
345
|
def _ensure_cache(self) -> CachedData:
|
|
@@ -21,6 +21,7 @@ class DetectedStores:
|
|
|
21
21
|
workflow_states: list[str] | None = None
|
|
22
22
|
comments: str | None = None
|
|
23
23
|
projects: str | None = None
|
|
24
|
+
issue_content: str | None = None # Y.js encoded issue descriptions
|
|
24
25
|
|
|
25
26
|
|
|
26
27
|
def _is_issue_record(record: dict[str, Any]) -> bool:
|
|
@@ -68,6 +69,12 @@ def _is_project_record(record: dict[str, Any]) -> bool:
|
|
|
68
69
|
return required.issubset(record.keys())
|
|
69
70
|
|
|
70
71
|
|
|
72
|
+
def _is_issue_content_record(record: dict[str, Any]) -> bool:
|
|
73
|
+
"""Check if a record looks like issue content (Y.js encoded description)."""
|
|
74
|
+
required = {"issueId", "contentState"}
|
|
75
|
+
return required.issubset(record.keys())
|
|
76
|
+
|
|
77
|
+
|
|
71
78
|
def detect_stores(db: ccl_chromium_indexeddb.WrappedDatabase) -> DetectedStores:
|
|
72
79
|
"""
|
|
73
80
|
Detect object stores by sampling their first record.
|
|
@@ -109,6 +116,8 @@ def detect_stores(db: ccl_chromium_indexeddb.WrappedDatabase) -> DetectedStores:
|
|
|
109
116
|
result.comments = store_name
|
|
110
117
|
elif _is_project_record(val) and result.projects is None:
|
|
111
118
|
result.projects = store_name
|
|
119
|
+
elif _is_issue_content_record(val) and result.issue_content is None:
|
|
120
|
+
result.issue_content = store_name
|
|
112
121
|
|
|
113
122
|
break # Only check first record
|
|
114
123
|
except Exception:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: linear-mcp-fast
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: Fast MCP server for Linear - reads from Linear.app's local cache on macOS
|
|
5
5
|
Author: everything-chalna
|
|
6
6
|
License-Expression: MIT
|
|
@@ -32,18 +32,22 @@ While using the official Linear MCP with Claude Code, I noticed that **read oper
|
|
|
32
32
|
|
|
33
33
|
The problem:
|
|
34
34
|
- Official Linear MCP makes API calls for every read
|
|
35
|
+
- Issue descriptions require separate API calls
|
|
35
36
|
- Responses include excessive metadata (full user objects, workflow states, etc.)
|
|
36
37
|
- Context window fills up quickly when exploring issues
|
|
37
38
|
- Slower response times due to network latency
|
|
38
39
|
|
|
39
40
|
My solution: **Read directly from Linear.app's local cache.**
|
|
40
41
|
|
|
42
|
+
Linear.app stores issue descriptions in Y.js CRDT format. This package decodes them locally, so you get descriptions without API calls.
|
|
43
|
+
|
|
41
44
|
Linear.app (Electron) syncs all your data to a local IndexedDB. This MCP server reads from that cache, giving you:
|
|
42
45
|
|
|
43
46
|
- **Zero API calls** - Instant reads from disk
|
|
44
47
|
- **Smaller responses** - Only the fields you need
|
|
45
48
|
- **Offline access** - Works without internet
|
|
46
49
|
- **Faster iteration** - No rate limits, no latency
|
|
50
|
+
- **Issue descriptions** - Extracts text from Y.js encoded content (v0.3.0+)
|
|
47
51
|
|
|
48
52
|
## Requirements
|
|
49
53
|
|
|
@@ -146,10 +150,16 @@ IndexedDB (LevelDB)
|
|
|
146
150
|
~/Library/Application Support/Linear/IndexedDB/...
|
|
147
151
|
↓ read by
|
|
148
152
|
linear-mcp-fast
|
|
149
|
-
↓
|
|
153
|
+
↓ decodes Y.js CRDT content
|
|
150
154
|
Fast, offline access to issues, teams, users, projects
|
|
151
155
|
```
|
|
152
156
|
|
|
157
|
+
### Issue Descriptions
|
|
158
|
+
|
|
159
|
+
Linear stores issue descriptions in a separate `contentState` field using Y.js CRDT encoding. This package decodes the binary format to extract readable text, so `get_issue` returns the description without an API call.
|
|
160
|
+
|
|
161
|
+
Note: The extraction is text-based (not full Y.js parsing), so some formatting may be lost. For rich markdown content, use the official Linear MCP.
|
|
162
|
+
|
|
153
163
|
## Troubleshooting
|
|
154
164
|
|
|
155
165
|
**"Linear database not found"**
|
|
@@ -23,17 +23,17 @@ ccl_simplesnappy/__init__.py,sha256=OqArK0MfdVl2oMw1MwpWSYWWVLFT-VLIWnEXXCsacJo,
|
|
|
23
23
|
ccl_simplesnappy/ccl_simplesnappy.py,sha256=dLv1wejr2vCa2b_ZinozXVfcSsZIzJrt5ZkyxA3cQXA,10461
|
|
24
24
|
linear_mcp_fast/__init__.py,sha256=T-ioPzoZXC3a_zLilZuDBCXUn8nDjvITS7svk7BwWmY,138
|
|
25
25
|
linear_mcp_fast/__main__.py,sha256=2wkhXADcE2oGdtEpGrIvvEe9YGKjpwnJ3DBWghkVQKk,124
|
|
26
|
-
linear_mcp_fast/reader.py,sha256=
|
|
26
|
+
linear_mcp_fast/reader.py,sha256=wa2FtrGVq92aa98mVdQmxb8OgemaS2Az6zclVgicEo0,20382
|
|
27
27
|
linear_mcp_fast/server.py,sha256=Ha9kCLb_-V7c0HTSz3navb9EEWNmsTh320vRhI8ppmw,12757
|
|
28
|
-
linear_mcp_fast/store_detector.py,sha256=
|
|
28
|
+
linear_mcp_fast/store_detector.py,sha256=BkwKelUlOCkn-e2pDtP5KcITb6juij7zmBOUOnNHc8Y,4585
|
|
29
29
|
tools_and_utilities/Chromium_dump_local_storage.py,sha256=gG-pKFFk6lo332LQy2JvInlQh9Zldm5zAsuibb-dBkQ,4337
|
|
30
30
|
tools_and_utilities/Chromium_dump_session_storage.py,sha256=17BKFWioo6fPwYkH58QycJCA-z85RtWMBXsJ_29hHQs,3484
|
|
31
31
|
tools_and_utilities/benchmark.py,sha256=fyD5U6yI7Y0TkyhYtvvaHyk9Y2jJe2yxYWoFPQWypzA,1089
|
|
32
32
|
tools_and_utilities/ccl_chrome_audit.py,sha256=irGyYJae0apZDZCn23jMKmY3tYQgWyZEL8vdUBcHLZk,24695
|
|
33
33
|
tools_and_utilities/dump_indexeddb_details.py,sha256=ipNWLKPQoSNhCtPHKWvMWpKu8FhCnvc4Rciyx-90boI,2298
|
|
34
34
|
tools_and_utilities/dump_leveldb.py,sha256=hj7QnOHG64KK2fKsZ9qQOVqUUmHUtxUZqPYl4EZJO9U,1882
|
|
35
|
-
linear_mcp_fast-0.
|
|
36
|
-
linear_mcp_fast-0.
|
|
37
|
-
linear_mcp_fast-0.
|
|
38
|
-
linear_mcp_fast-0.
|
|
39
|
-
linear_mcp_fast-0.
|
|
35
|
+
linear_mcp_fast-0.3.1.dist-info/METADATA,sha256=kcsnPWx6VVZ6NAmOJu1oRPccW0FHJRO1t0TziS3M6tU,5295
|
|
36
|
+
linear_mcp_fast-0.3.1.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
37
|
+
linear_mcp_fast-0.3.1.dist-info/entry_points.txt,sha256=Aa98tAkWz_08mS_SRyfyx0k3PuMBQoMygT88HCKMyWk,57
|
|
38
|
+
linear_mcp_fast-0.3.1.dist-info/top_level.txt,sha256=j-O2BoBpFBpGyTl2V1cp0ZjxZAQwpkweeNxG4BcQ7io,73
|
|
39
|
+
linear_mcp_fast-0.3.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|