linear-mcp-fast 0.2.5__py3-none-any.whl → 0.3.0__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 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": val.get("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.2.5
3
+ Version: 0.3.0
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
@@ -163,10 +163,6 @@ ls ~/Library/Application\ Support/Linear/IndexedDB/
163
163
 
164
164
  The local cache updates when Linear.app syncs. Open Linear.app to refresh.
165
165
 
166
- **Returns 0 issues**
167
-
168
- Multiple IndexedDB databases may exist. Version 0.2.2+ automatically finds the correct one.
169
-
170
166
  ## License
171
167
 
172
168
  MIT
@@ -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=FQithUV-F2SwMTyi61JlA2YjrlUgVrGme1yiDZQFTvY,16492
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=5cnTwS-LPsgS9NivNxKa3bPv4mwAnZsi40-kXszejMI,4142
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.2.5.dist-info/METADATA,sha256=T7IeVGh3uq4Wj3HtBaQS9yZYC5AQVWqNZMnHm5W8sjw,4729
36
- linear_mcp_fast-0.2.5.dist-info/WHEEL,sha256=qELbo2s1Yzl39ZmrAibXA2jjPLUYfnVhUNTlyF1rq0Y,92
37
- linear_mcp_fast-0.2.5.dist-info/entry_points.txt,sha256=Aa98tAkWz_08mS_SRyfyx0k3PuMBQoMygT88HCKMyWk,57
38
- linear_mcp_fast-0.2.5.dist-info/top_level.txt,sha256=j-O2BoBpFBpGyTl2V1cp0ZjxZAQwpkweeNxG4BcQ7io,73
39
- linear_mcp_fast-0.2.5.dist-info/RECORD,,
35
+ linear_mcp_fast-0.3.0.dist-info/METADATA,sha256=HVEhe0EshfmCWmrMHCVawvQR_-_7FSVei2mEEC26LGY,4614
36
+ linear_mcp_fast-0.3.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
37
+ linear_mcp_fast-0.3.0.dist-info/entry_points.txt,sha256=Aa98tAkWz_08mS_SRyfyx0k3PuMBQoMygT88HCKMyWk,57
38
+ linear_mcp_fast-0.3.0.dist-info/top_level.txt,sha256=j-O2BoBpFBpGyTl2V1cp0ZjxZAQwpkweeNxG4BcQ7io,73
39
+ linear_mcp_fast-0.3.0.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.10.1)
2
+ Generator: setuptools (80.10.2)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5