linear-mcp-fast 0.1.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.
- ccl_chromium_reader/__init__.py +2 -0
- ccl_chromium_reader/ccl_chromium_cache.py +1335 -0
- ccl_chromium_reader/ccl_chromium_filesystem.py +302 -0
- ccl_chromium_reader/ccl_chromium_history.py +357 -0
- ccl_chromium_reader/ccl_chromium_indexeddb.py +1060 -0
- ccl_chromium_reader/ccl_chromium_localstorage.py +454 -0
- ccl_chromium_reader/ccl_chromium_notifications.py +268 -0
- ccl_chromium_reader/ccl_chromium_profile_folder.py +568 -0
- ccl_chromium_reader/ccl_chromium_sessionstorage.py +368 -0
- ccl_chromium_reader/ccl_chromium_snss2.py +332 -0
- ccl_chromium_reader/ccl_shared_proto_db_downloads.py +189 -0
- ccl_chromium_reader/common.py +19 -0
- ccl_chromium_reader/download_common.py +78 -0
- ccl_chromium_reader/profile_folder_protocols.py +276 -0
- ccl_chromium_reader/serialization_formats/__init__.py +0 -0
- ccl_chromium_reader/serialization_formats/ccl_blink_value_deserializer.py +401 -0
- ccl_chromium_reader/serialization_formats/ccl_easy_chromium_pickle.py +133 -0
- ccl_chromium_reader/serialization_formats/ccl_protobuff.py +276 -0
- ccl_chromium_reader/serialization_formats/ccl_v8_value_deserializer.py +627 -0
- ccl_chromium_reader/storage_formats/__init__.py +0 -0
- ccl_chromium_reader/storage_formats/ccl_leveldb.py +582 -0
- ccl_simplesnappy/__init__.py +1 -0
- ccl_simplesnappy/ccl_simplesnappy.py +306 -0
- linear_mcp_fast/__init__.py +8 -0
- linear_mcp_fast/__main__.py +6 -0
- linear_mcp_fast/reader.py +433 -0
- linear_mcp_fast/server.py +367 -0
- linear_mcp_fast/store_detector.py +117 -0
- linear_mcp_fast-0.1.0.dist-info/METADATA +160 -0
- linear_mcp_fast-0.1.0.dist-info/RECORD +39 -0
- linear_mcp_fast-0.1.0.dist-info/WHEEL +5 -0
- linear_mcp_fast-0.1.0.dist-info/entry_points.txt +2 -0
- linear_mcp_fast-0.1.0.dist-info/top_level.txt +4 -0
- tools_and_utilities/Chromium_dump_local_storage.py +111 -0
- tools_and_utilities/Chromium_dump_session_storage.py +92 -0
- tools_and_utilities/benchmark.py +35 -0
- tools_and_utilities/ccl_chrome_audit.py +651 -0
- tools_and_utilities/dump_indexeddb_details.py +59 -0
- tools_and_utilities/dump_leveldb.py +53 -0
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MCP Server for Linear Local Cache.
|
|
3
|
+
|
|
4
|
+
Provides fast, read-only access to Linear data from local cache.
|
|
5
|
+
For write operations, use the official Linear MCP server.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from mcp.server.fastmcp import FastMCP
|
|
11
|
+
|
|
12
|
+
from .reader import LinearLocalReader
|
|
13
|
+
|
|
14
|
+
mcp = FastMCP(
|
|
15
|
+
"Linear Local Cache",
|
|
16
|
+
instructions=(
|
|
17
|
+
"Fast, read-only access to Linear data from the local Linear.app cache on macOS. "
|
|
18
|
+
"Data freshness depends on Linear.app's last sync. "
|
|
19
|
+
"For write operations (comments, updates), use the official Linear MCP server."
|
|
20
|
+
),
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
_reader: LinearLocalReader | None = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_reader() -> LinearLocalReader:
|
|
27
|
+
"""Get or create the LinearLocalReader instance."""
|
|
28
|
+
global _reader
|
|
29
|
+
if _reader is None:
|
|
30
|
+
_reader = LinearLocalReader()
|
|
31
|
+
return _reader
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _parse_datetime(dt_value: Any) -> float | None:
|
|
35
|
+
"""Parse a datetime value to Unix timestamp."""
|
|
36
|
+
if dt_value is None:
|
|
37
|
+
return None
|
|
38
|
+
if isinstance(dt_value, (int, float)):
|
|
39
|
+
if dt_value > 1e12:
|
|
40
|
+
return dt_value / 1000
|
|
41
|
+
return dt_value
|
|
42
|
+
if isinstance(dt_value, str):
|
|
43
|
+
from datetime import datetime
|
|
44
|
+
try:
|
|
45
|
+
dt_str = dt_value.replace("Z", "+00:00")
|
|
46
|
+
dt = datetime.fromisoformat(dt_str)
|
|
47
|
+
return dt.timestamp()
|
|
48
|
+
except ValueError:
|
|
49
|
+
return None
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@mcp.tool()
|
|
54
|
+
def list_issues(
|
|
55
|
+
assignee: str | None = None,
|
|
56
|
+
team: str | None = None,
|
|
57
|
+
state_type: str | None = None,
|
|
58
|
+
priority: int | None = None,
|
|
59
|
+
limit: int = 50,
|
|
60
|
+
) -> dict[str, Any]:
|
|
61
|
+
"""
|
|
62
|
+
List issues from local cache with optional filters.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
assignee: Filter by assignee name (partial match)
|
|
66
|
+
team: Filter by team key (e.g., 'UK')
|
|
67
|
+
state_type: Filter by state type (started, unstarted, completed, canceled, backlog)
|
|
68
|
+
priority: Filter by priority (1=Urgent, 2=High, 3=Medium, 4=Low)
|
|
69
|
+
limit: Maximum number of issues (default 50, max 100)
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
Dictionary with issues array and totalCount
|
|
73
|
+
"""
|
|
74
|
+
reader = get_reader()
|
|
75
|
+
limit = min(limit, 100)
|
|
76
|
+
|
|
77
|
+
assignee_id = None
|
|
78
|
+
if assignee:
|
|
79
|
+
user = reader.find_user(assignee)
|
|
80
|
+
if user:
|
|
81
|
+
assignee_id = user["id"]
|
|
82
|
+
else:
|
|
83
|
+
return {"issues": [], "totalCount": 0}
|
|
84
|
+
|
|
85
|
+
team_id = None
|
|
86
|
+
if team:
|
|
87
|
+
team_obj = reader.find_team(team)
|
|
88
|
+
if team_obj:
|
|
89
|
+
team_id = team_obj["id"]
|
|
90
|
+
else:
|
|
91
|
+
return {"issues": [], "totalCount": 0}
|
|
92
|
+
|
|
93
|
+
all_issues = sorted(
|
|
94
|
+
reader.issues.values(),
|
|
95
|
+
key=lambda x: (x.get("priority") or 4, x.get("updatedAt") or ""),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
filtered = []
|
|
99
|
+
for issue in all_issues:
|
|
100
|
+
if assignee_id and issue.get("assigneeId") != assignee_id:
|
|
101
|
+
continue
|
|
102
|
+
if team_id and issue.get("teamId") != team_id:
|
|
103
|
+
continue
|
|
104
|
+
if state_type and reader.get_state_type(issue.get("stateId", "")) != state_type:
|
|
105
|
+
continue
|
|
106
|
+
if priority is not None and issue.get("priority") != priority:
|
|
107
|
+
continue
|
|
108
|
+
filtered.append(issue)
|
|
109
|
+
|
|
110
|
+
total_count = len(filtered)
|
|
111
|
+
page = filtered[:limit]
|
|
112
|
+
|
|
113
|
+
results = []
|
|
114
|
+
for issue in page:
|
|
115
|
+
results.append({
|
|
116
|
+
"identifier": issue.get("identifier"),
|
|
117
|
+
"title": issue.get("title"),
|
|
118
|
+
"priority": issue.get("priority"),
|
|
119
|
+
"state": reader.get_state_name(issue.get("stateId", "")),
|
|
120
|
+
"stateType": reader.get_state_type(issue.get("stateId", "")),
|
|
121
|
+
"assignee": reader.get_user_name(issue.get("assigneeId")),
|
|
122
|
+
"dueDate": issue.get("dueDate"),
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
return {"issues": results, "totalCount": total_count}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@mcp.tool()
|
|
129
|
+
def get_issue(identifier: str) -> dict[str, Any] | None:
|
|
130
|
+
"""
|
|
131
|
+
Get issue details by identifier (e.g., 'UK-55').
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
identifier: Issue identifier like 'UK-55'
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
Issue details with comments, or None if not found
|
|
138
|
+
"""
|
|
139
|
+
reader = get_reader()
|
|
140
|
+
issue = reader.get_issue_by_identifier(identifier)
|
|
141
|
+
|
|
142
|
+
if not issue:
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
comments = reader.get_comments_for_issue(issue["id"])
|
|
146
|
+
enriched_comments = []
|
|
147
|
+
for comment in comments:
|
|
148
|
+
user = reader.users.get(comment.get("userId", ""), {})
|
|
149
|
+
enriched_comments.append({
|
|
150
|
+
"author": user.get("name", "Unknown"),
|
|
151
|
+
"body": comment.get("body", ""),
|
|
152
|
+
"createdAt": comment.get("createdAt"),
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
"identifier": issue.get("identifier"),
|
|
157
|
+
"title": issue.get("title"),
|
|
158
|
+
"description": issue.get("description"),
|
|
159
|
+
"priority": issue.get("priority"),
|
|
160
|
+
"estimate": issue.get("estimate"),
|
|
161
|
+
"state": reader.get_state_name(issue.get("stateId", "")),
|
|
162
|
+
"stateType": reader.get_state_type(issue.get("stateId", "")),
|
|
163
|
+
"assignee": reader.get_user_name(issue.get("assigneeId")),
|
|
164
|
+
"project": reader.get_project_name(issue.get("projectId")),
|
|
165
|
+
"dueDate": issue.get("dueDate"),
|
|
166
|
+
"createdAt": issue.get("createdAt"),
|
|
167
|
+
"updatedAt": issue.get("updatedAt"),
|
|
168
|
+
"comments": enriched_comments,
|
|
169
|
+
"url": f"https://linear.app/issue/{issue.get('identifier')}",
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@mcp.tool()
|
|
174
|
+
def search_issues(query: str, limit: int = 20) -> dict[str, Any]:
|
|
175
|
+
"""
|
|
176
|
+
Search issues by title.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
query: Search query (case-insensitive)
|
|
180
|
+
limit: Maximum results (default 20, max 100)
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
Dictionary with matching issues
|
|
184
|
+
"""
|
|
185
|
+
reader = get_reader()
|
|
186
|
+
limit = min(limit, 100)
|
|
187
|
+
query_lower = query.lower()
|
|
188
|
+
|
|
189
|
+
all_issues = sorted(
|
|
190
|
+
reader.issues.values(),
|
|
191
|
+
key=lambda x: (x.get("priority") or 4, x.get("id", "")),
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
filtered = []
|
|
195
|
+
for issue in all_issues:
|
|
196
|
+
title = issue.get("title", "") or ""
|
|
197
|
+
identifier = issue.get("identifier", "") or ""
|
|
198
|
+
if query_lower in title.lower() or query_lower in identifier.lower():
|
|
199
|
+
filtered.append(issue)
|
|
200
|
+
|
|
201
|
+
match_count = len(filtered)
|
|
202
|
+
page = filtered[:limit]
|
|
203
|
+
|
|
204
|
+
results = []
|
|
205
|
+
for issue in page:
|
|
206
|
+
results.append({
|
|
207
|
+
"identifier": issue.get("identifier"),
|
|
208
|
+
"title": issue.get("title"),
|
|
209
|
+
"state": reader.get_state_name(issue.get("stateId", "")),
|
|
210
|
+
"stateType": reader.get_state_type(issue.get("stateId", "")),
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
return {"issues": results, "matchCount": match_count}
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@mcp.tool()
|
|
217
|
+
def get_my_issues(
|
|
218
|
+
name: str,
|
|
219
|
+
state_type: str | None = None,
|
|
220
|
+
limit: int = 30,
|
|
221
|
+
) -> dict[str, Any]:
|
|
222
|
+
"""
|
|
223
|
+
Get issues assigned to a user.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
name: User name to search for
|
|
227
|
+
state_type: Optional filter (started, unstarted, completed, canceled, backlog)
|
|
228
|
+
limit: Maximum issues (default 30, max 100)
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
User info with their issues
|
|
232
|
+
"""
|
|
233
|
+
reader = get_reader()
|
|
234
|
+
limit = min(limit, 100)
|
|
235
|
+
|
|
236
|
+
user = reader.find_user(name)
|
|
237
|
+
if not user:
|
|
238
|
+
return {"error": f"User '{name}' not found"}
|
|
239
|
+
|
|
240
|
+
all_issues = sorted(
|
|
241
|
+
reader.get_issues_for_user(user["id"]),
|
|
242
|
+
key=lambda x: (x.get("priority") or 4, x.get("id", "")),
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
counts_by_state: dict[str, int] = {}
|
|
246
|
+
for issue in all_issues:
|
|
247
|
+
issue_state_type = reader.get_state_type(issue.get("stateId", ""))
|
|
248
|
+
counts_by_state[issue_state_type] = counts_by_state.get(issue_state_type, 0) + 1
|
|
249
|
+
|
|
250
|
+
if state_type:
|
|
251
|
+
all_issues = [
|
|
252
|
+
i for i in all_issues
|
|
253
|
+
if reader.get_state_type(i.get("stateId", "")) == state_type
|
|
254
|
+
]
|
|
255
|
+
|
|
256
|
+
page = all_issues[:limit]
|
|
257
|
+
|
|
258
|
+
results = []
|
|
259
|
+
for issue in page:
|
|
260
|
+
results.append({
|
|
261
|
+
"identifier": issue.get("identifier"),
|
|
262
|
+
"title": issue.get("title"),
|
|
263
|
+
"priority": issue.get("priority"),
|
|
264
|
+
"state": reader.get_state_name(issue.get("stateId", "")),
|
|
265
|
+
"stateType": reader.get_state_type(issue.get("stateId", "")),
|
|
266
|
+
"dueDate": issue.get("dueDate"),
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
return {
|
|
270
|
+
"user": {"name": user.get("name"), "email": user.get("email")},
|
|
271
|
+
"totalIssues": sum(counts_by_state.values()),
|
|
272
|
+
"countsByState": counts_by_state,
|
|
273
|
+
"issues": results,
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
@mcp.tool()
|
|
278
|
+
def list_teams() -> list[dict[str, Any]]:
|
|
279
|
+
"""
|
|
280
|
+
List all teams with issue counts.
|
|
281
|
+
|
|
282
|
+
Returns:
|
|
283
|
+
List of teams
|
|
284
|
+
"""
|
|
285
|
+
reader = get_reader()
|
|
286
|
+
results = []
|
|
287
|
+
|
|
288
|
+
for team in reader.teams.values():
|
|
289
|
+
issue_count = sum(
|
|
290
|
+
1 for i in reader.issues.values() if i.get("teamId") == team["id"]
|
|
291
|
+
)
|
|
292
|
+
results.append({
|
|
293
|
+
"key": team.get("key"),
|
|
294
|
+
"name": team.get("name"),
|
|
295
|
+
"issueCount": issue_count,
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
results.sort(key=lambda x: x.get("key", ""))
|
|
299
|
+
return results
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
@mcp.tool()
|
|
303
|
+
def list_projects(team: str | None = None) -> list[dict[str, Any]]:
|
|
304
|
+
"""
|
|
305
|
+
List all projects with issue counts.
|
|
306
|
+
|
|
307
|
+
Args:
|
|
308
|
+
team: Optional team key to filter
|
|
309
|
+
|
|
310
|
+
Returns:
|
|
311
|
+
List of projects
|
|
312
|
+
"""
|
|
313
|
+
reader = get_reader()
|
|
314
|
+
|
|
315
|
+
team_id = None
|
|
316
|
+
if team:
|
|
317
|
+
team_obj = reader.find_team(team)
|
|
318
|
+
if team_obj:
|
|
319
|
+
team_id = team_obj["id"]
|
|
320
|
+
else:
|
|
321
|
+
return []
|
|
322
|
+
|
|
323
|
+
results = []
|
|
324
|
+
for project in reader.projects.values():
|
|
325
|
+
if team_id and team_id not in project.get("teamIds", []):
|
|
326
|
+
continue
|
|
327
|
+
|
|
328
|
+
issue_count = sum(
|
|
329
|
+
1 for i in reader.issues.values() if i.get("projectId") == project["id"]
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
results.append({
|
|
333
|
+
"name": project.get("name"),
|
|
334
|
+
"state": project.get("state"),
|
|
335
|
+
"issueCount": issue_count,
|
|
336
|
+
"startDate": project.get("startDate"),
|
|
337
|
+
"targetDate": project.get("targetDate"),
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
results.sort(key=lambda x: x.get("name", "") or "")
|
|
341
|
+
return results
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
@mcp.tool()
|
|
345
|
+
def get_summary() -> dict[str, Any]:
|
|
346
|
+
"""
|
|
347
|
+
Get a summary of local cache data.
|
|
348
|
+
|
|
349
|
+
Returns:
|
|
350
|
+
Counts of teams, users, issues, projects, comments
|
|
351
|
+
"""
|
|
352
|
+
reader = get_reader()
|
|
353
|
+
summary = reader.get_summary()
|
|
354
|
+
|
|
355
|
+
# Add state breakdown
|
|
356
|
+
state_counts: dict[str, int] = {}
|
|
357
|
+
for issue in reader.issues.values():
|
|
358
|
+
state_type = reader.get_state_type(issue.get("stateId", ""))
|
|
359
|
+
state_counts[state_type] = state_counts.get(state_type, 0) + 1
|
|
360
|
+
|
|
361
|
+
summary["issuesByState"] = state_counts
|
|
362
|
+
return summary
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def main():
|
|
366
|
+
"""Run the MCP server."""
|
|
367
|
+
mcp.run()
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Auto-detect Linear IndexedDB object store hashes by sampling records.
|
|
3
|
+
|
|
4
|
+
Linear uses hash-based object store names that may change between versions.
|
|
5
|
+
This module detects stores by examining the structure of their records.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from ccl_chromium_reader import ccl_chromium_indexeddb # type: ignore
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class DetectedStores:
|
|
16
|
+
"""Container for detected object store names."""
|
|
17
|
+
|
|
18
|
+
issues: str | None = None
|
|
19
|
+
teams: str | None = None
|
|
20
|
+
users: list[str] | None = None
|
|
21
|
+
workflow_states: list[str] | None = None
|
|
22
|
+
comments: str | None = None
|
|
23
|
+
projects: str | None = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _is_issue_record(record: dict[str, Any]) -> bool:
|
|
27
|
+
"""Check if a record looks like an issue."""
|
|
28
|
+
required = {"number", "teamId", "stateId", "title"}
|
|
29
|
+
return required.issubset(record.keys())
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _is_user_record(record: dict[str, Any]) -> bool:
|
|
33
|
+
"""Check if a record looks like a user."""
|
|
34
|
+
required = {"name", "displayName", "email"}
|
|
35
|
+
has_required = required.issubset(record.keys())
|
|
36
|
+
has_avatar = "avatarUrl" in record or "avatar" in record
|
|
37
|
+
return has_required and has_avatar
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _is_team_record(record: dict[str, Any]) -> bool:
|
|
41
|
+
"""Check if a record looks like a team."""
|
|
42
|
+
if not {"key", "name"}.issubset(record.keys()):
|
|
43
|
+
return False
|
|
44
|
+
key = record.get("key")
|
|
45
|
+
if not isinstance(key, str):
|
|
46
|
+
return False
|
|
47
|
+
return key.isupper() and key.isalpha() and len(key) <= 10
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _is_workflow_state_record(record: dict[str, Any]) -> bool:
|
|
51
|
+
"""Check if a record looks like a workflow state."""
|
|
52
|
+
if not {"name", "type", "color"}.issubset(record.keys()):
|
|
53
|
+
return False
|
|
54
|
+
state_type = record.get("type")
|
|
55
|
+
valid_types = {"started", "unstarted", "completed", "canceled", "backlog"}
|
|
56
|
+
return state_type in valid_types
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _is_comment_record(record: dict[str, Any]) -> bool:
|
|
60
|
+
"""Check if a record looks like a comment."""
|
|
61
|
+
required = {"issueId", "userId", "bodyData", "createdAt"}
|
|
62
|
+
return required.issubset(record.keys())
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _is_project_record(record: dict[str, Any]) -> bool:
|
|
66
|
+
"""Check if a record looks like a project."""
|
|
67
|
+
required = {"name", "description", "teamIds", "startDate", "targetDate", "statusId"}
|
|
68
|
+
return required.issubset(record.keys())
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def detect_stores(db: ccl_chromium_indexeddb.WrappedDatabase) -> DetectedStores:
|
|
72
|
+
"""
|
|
73
|
+
Detect object stores by sampling their first record.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
db: The wrapped IndexedDB database to scan.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
DetectedStores with detected store names for each entity type.
|
|
80
|
+
"""
|
|
81
|
+
result = DetectedStores(users=[], workflow_states=[])
|
|
82
|
+
|
|
83
|
+
for store_name in db.object_store_names:
|
|
84
|
+
if store_name is None or store_name.startswith("_") or "_partial" in store_name:
|
|
85
|
+
continue
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
store = db[store_name]
|
|
89
|
+
for record in store.iterate_records():
|
|
90
|
+
val = record.value
|
|
91
|
+
if not isinstance(val, dict):
|
|
92
|
+
break
|
|
93
|
+
|
|
94
|
+
if _is_issue_record(val) and result.issues is None:
|
|
95
|
+
result.issues = store_name
|
|
96
|
+
elif _is_team_record(val) and result.teams is None:
|
|
97
|
+
result.teams = store_name
|
|
98
|
+
elif _is_user_record(val) and store_name not in (result.users or []):
|
|
99
|
+
if result.users is None:
|
|
100
|
+
result.users = []
|
|
101
|
+
result.users.append(store_name)
|
|
102
|
+
elif _is_workflow_state_record(val) and store_name not in (
|
|
103
|
+
result.workflow_states or []
|
|
104
|
+
):
|
|
105
|
+
if result.workflow_states is None:
|
|
106
|
+
result.workflow_states = []
|
|
107
|
+
result.workflow_states.append(store_name)
|
|
108
|
+
elif _is_comment_record(val) and result.comments is None:
|
|
109
|
+
result.comments = store_name
|
|
110
|
+
elif _is_project_record(val) and result.projects is None:
|
|
111
|
+
result.projects = store_name
|
|
112
|
+
|
|
113
|
+
break # Only check first record
|
|
114
|
+
except Exception:
|
|
115
|
+
continue
|
|
116
|
+
|
|
117
|
+
return result
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: linear-mcp-fast
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Fast MCP server for Linear - reads from Linear.app's local cache on macOS
|
|
5
|
+
Author: everything-chalna
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/everything-chalna/linear-mcp-fast
|
|
8
|
+
Project-URL: Repository, https://github.com/everything-chalna/linear-mcp-fast
|
|
9
|
+
Project-URL: Issues, https://github.com/everything-chalna/linear-mcp-fast/issues
|
|
10
|
+
Keywords: linear,mcp,cache,indexeddb,macos
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: MacOS
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
Requires-Dist: mcp>=1.0.0
|
|
23
|
+
Requires-Dist: brotli>=1.0.0
|
|
24
|
+
|
|
25
|
+
# linear-mcp-fast
|
|
26
|
+
|
|
27
|
+
Fast, read-only MCP server for Linear that reads from Linear.app's local cache on macOS.
|
|
28
|
+
|
|
29
|
+
**Why?**
|
|
30
|
+
- **Instant**: No API calls, reads directly from local IndexedDB cache
|
|
31
|
+
- **Offline**: Works without internet
|
|
32
|
+
- **Lower context**: Smaller responses for AI assistants
|
|
33
|
+
|
|
34
|
+
**Use with**: Official Linear MCP for write operations (comments, updates).
|
|
35
|
+
|
|
36
|
+
## Installation
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install linear-mcp-fast
|
|
40
|
+
# or
|
|
41
|
+
uv pip install linear-mcp-fast
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Setup
|
|
45
|
+
|
|
46
|
+
### Claude Code
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
# 1. Add linear-mcp-fast (reads from local cache)
|
|
50
|
+
claude mcp add linear-fast -- uvx linear-mcp-fast
|
|
51
|
+
|
|
52
|
+
# 2. Add official Linear MCP (for writes)
|
|
53
|
+
claude mcp add --transport http linear https://mcp.linear.app/mcp
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Now you have:
|
|
57
|
+
- `linear-fast` → Fast reads from local cache
|
|
58
|
+
- `linear` → Writes (comments, updates)
|
|
59
|
+
|
|
60
|
+
### Claude Desktop
|
|
61
|
+
|
|
62
|
+
Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
|
|
63
|
+
|
|
64
|
+
```json
|
|
65
|
+
{
|
|
66
|
+
"mcpServers": {
|
|
67
|
+
"linear-fast": {
|
|
68
|
+
"command": "uvx",
|
|
69
|
+
"args": ["linear-mcp-fast"]
|
|
70
|
+
},
|
|
71
|
+
"linear": {
|
|
72
|
+
"command": "npx",
|
|
73
|
+
"args": ["-y", "mcp-remote", "https://mcp.linear.app/mcp"]
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Cursor / VS Code
|
|
80
|
+
|
|
81
|
+
```json
|
|
82
|
+
{
|
|
83
|
+
"mcpServers": {
|
|
84
|
+
"linear-fast": {
|
|
85
|
+
"command": "uvx",
|
|
86
|
+
"args": ["linear-mcp-fast"]
|
|
87
|
+
},
|
|
88
|
+
"linear": {
|
|
89
|
+
"command": "npx",
|
|
90
|
+
"args": ["-y", "mcp-remote", "https://mcp.linear.app/mcp"]
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## Prerequisites
|
|
99
|
+
|
|
100
|
+
- **macOS** (Linear.app stores cache in `~/Library/Application Support/Linear/`)
|
|
101
|
+
- **Linear.app** installed and opened at least once
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
## Available Tools
|
|
106
|
+
|
|
107
|
+
### Reading (linear-fast)
|
|
108
|
+
|
|
109
|
+
| Tool | Description |
|
|
110
|
+
|------|-------------|
|
|
111
|
+
| `list_issues` | List issues with filters (assignee, team, state, priority) |
|
|
112
|
+
| `get_issue` | Get issue details with comments |
|
|
113
|
+
| `search_issues` | Search issues by title |
|
|
114
|
+
| `get_my_issues` | Get issues assigned to a user |
|
|
115
|
+
| `list_teams` | List all teams |
|
|
116
|
+
| `list_projects` | List all projects |
|
|
117
|
+
| `get_summary` | Get cache summary |
|
|
118
|
+
|
|
119
|
+
### Writing (official Linear MCP)
|
|
120
|
+
|
|
121
|
+
Use the official Linear MCP (`linear`) for:
|
|
122
|
+
- Creating/updating issues
|
|
123
|
+
- Adding comments
|
|
124
|
+
- Changing status
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
## Troubleshooting
|
|
129
|
+
|
|
130
|
+
### "Linear database not found"
|
|
131
|
+
|
|
132
|
+
Linear.app needs to be installed and opened at least once:
|
|
133
|
+
```bash
|
|
134
|
+
ls ~/Library/Application\ Support/Linear/IndexedDB/
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### Data seems stale
|
|
138
|
+
|
|
139
|
+
Local cache is updated when Linear.app syncs. Open Linear.app to refresh.
|
|
140
|
+
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
## How It Works
|
|
144
|
+
|
|
145
|
+
```
|
|
146
|
+
Linear.app (Electron)
|
|
147
|
+
↓ syncs to
|
|
148
|
+
IndexedDB (LevelDB format)
|
|
149
|
+
~/Library/Application Support/Linear/IndexedDB/...
|
|
150
|
+
↓ read by
|
|
151
|
+
linear-mcp-fast
|
|
152
|
+
↓ provides
|
|
153
|
+
Fast read-only access to issues, users, teams, comments
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
## License
|
|
159
|
+
|
|
160
|
+
MIT
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
ccl_chromium_reader/__init__.py,sha256=ktLJfrcRmDRw8kc81l71lHYNjGuqXCkzBMYEvRhQLP4,93
|
|
2
|
+
ccl_chromium_reader/ccl_chromium_cache.py,sha256=JE5DnhZTJ8QPuaTSJykzNtm7SrKV92d-hJR3ZEkaYQ0,49588
|
|
3
|
+
ccl_chromium_reader/ccl_chromium_filesystem.py,sha256=R4bBkDvtbw0Y6lZjg98bVfJaRpvD9Ckvl6gThVBViT8,12949
|
|
4
|
+
ccl_chromium_reader/ccl_chromium_history.py,sha256=ULexw7SV8vR0Mwx-ZMcstB-eSDLcrUSUkqji1tMS2CQ,11910
|
|
5
|
+
ccl_chromium_reader/ccl_chromium_indexeddb.py,sha256=arHjNf6fWgYwmK7IKOY59Dm3Z9T-X2t1TiIY3bE-Xyk,44498
|
|
6
|
+
ccl_chromium_reader/ccl_chromium_localstorage.py,sha256=DHr-KSxDJa49l4DjraoFSGDUsFVK1wpp2hB6EShx4ug,20269
|
|
7
|
+
ccl_chromium_reader/ccl_chromium_notifications.py,sha256=9pDKdMZFNTXfSN0lhwcPUd3XTS9ulwAjqWcujjJZiIU,10876
|
|
8
|
+
ccl_chromium_reader/ccl_chromium_profile_folder.py,sha256=psN3m40iha3W8Fn-Sb8V74HE4_hrcGKNyKJmZdtCV58,29076
|
|
9
|
+
ccl_chromium_reader/ccl_chromium_sessionstorage.py,sha256=4hHogY7pAMnzbTZ3Ikv42jd42lBDyjWp2UjTSp2nbCo,15848
|
|
10
|
+
ccl_chromium_reader/ccl_chromium_snss2.py,sha256=8rWyncs4l_l3dB-TaNPPIWjIxc3yWvBHwgkV0sm758s,12336
|
|
11
|
+
ccl_chromium_reader/ccl_shared_proto_db_downloads.py,sha256=9cEGFhlwXwHhtgXUt3tJ2IxNPgDPBwGRgdpInuyTfNI,8413
|
|
12
|
+
ccl_chromium_reader/common.py,sha256=ciCeGUUyXzz0IQ0nPFirEHVJB9uat4nnGp123Mdq9eE,618
|
|
13
|
+
ccl_chromium_reader/download_common.py,sha256=u4jFOdDqfBqh9t8TV5iiGoy3P2lJRhoUx60V5eLSC40,2977
|
|
14
|
+
ccl_chromium_reader/profile_folder_protocols.py,sha256=1oBpowQraWJ_ckTX4xrjRkNPmZEX8bhad6Yrxg8kqP8,12306
|
|
15
|
+
ccl_chromium_reader/serialization_formats/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
16
|
+
ccl_chromium_reader/serialization_formats/ccl_blink_value_deserializer.py,sha256=jqHf9I4fwXam1DMS5iVcbX_G-m-TQcaC_CwhyNylQJM,18647
|
|
17
|
+
ccl_chromium_reader/serialization_formats/ccl_easy_chromium_pickle.py,sha256=YQ5Dofla2OSR-3p32FO1rGTXn8qD7942lxU6VE_-PRE,4388
|
|
18
|
+
ccl_chromium_reader/serialization_formats/ccl_protobuff.py,sha256=3OJUcy2ZwR1qU8NrjEyDItt5uqMpo0TM0ixWqFNbb2s,8990
|
|
19
|
+
ccl_chromium_reader/serialization_formats/ccl_v8_value_deserializer.py,sha256=c_bJHGVzqKs1X0dw0EwojNmoh5d1THI1OiKzgu-wLWY,22979
|
|
20
|
+
ccl_chromium_reader/storage_formats/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
21
|
+
ccl_chromium_reader/storage_formats/ccl_leveldb.py,sha256=KEM6hQryZZrWZ6KafaHYX69QAMT07BuCvfBIpkAeFsA,22989
|
|
22
|
+
ccl_simplesnappy/__init__.py,sha256=OqArK0MfdVl2oMw1MwpWSYWWVLFT-VLIWnEXXCsacJo,59
|
|
23
|
+
ccl_simplesnappy/ccl_simplesnappy.py,sha256=dLv1wejr2vCa2b_ZinozXVfcSsZIzJrt5ZkyxA3cQXA,10461
|
|
24
|
+
linear_mcp_fast/__init__.py,sha256=T-ioPzoZXC3a_zLilZuDBCXUn8nDjvITS7svk7BwWmY,138
|
|
25
|
+
linear_mcp_fast/__main__.py,sha256=2wkhXADcE2oGdtEpGrIvvEe9YGKjpwnJ3DBWghkVQKk,124
|
|
26
|
+
linear_mcp_fast/reader.py,sha256=lf-bHccLSzmP0Ez4UeRLLYoYvVwlZOByWnfcUTtuuV0,16249
|
|
27
|
+
linear_mcp_fast/server.py,sha256=cz-kSNy4y4xSRJRx_x9IyNl7TlwU9U3rA0pH1ro3QWs,10389
|
|
28
|
+
linear_mcp_fast/store_detector.py,sha256=5cnTwS-LPsgS9NivNxKa3bPv4mwAnZsi40-kXszejMI,4142
|
|
29
|
+
tools_and_utilities/Chromium_dump_local_storage.py,sha256=gG-pKFFk6lo332LQy2JvInlQh9Zldm5zAsuibb-dBkQ,4337
|
|
30
|
+
tools_and_utilities/Chromium_dump_session_storage.py,sha256=17BKFWioo6fPwYkH58QycJCA-z85RtWMBXsJ_29hHQs,3484
|
|
31
|
+
tools_and_utilities/benchmark.py,sha256=fyD5U6yI7Y0TkyhYtvvaHyk9Y2jJe2yxYWoFPQWypzA,1089
|
|
32
|
+
tools_and_utilities/ccl_chrome_audit.py,sha256=irGyYJae0apZDZCn23jMKmY3tYQgWyZEL8vdUBcHLZk,24695
|
|
33
|
+
tools_and_utilities/dump_indexeddb_details.py,sha256=ipNWLKPQoSNhCtPHKWvMWpKu8FhCnvc4Rciyx-90boI,2298
|
|
34
|
+
tools_and_utilities/dump_leveldb.py,sha256=hj7QnOHG64KK2fKsZ9qQOVqUUmHUtxUZqPYl4EZJO9U,1882
|
|
35
|
+
linear_mcp_fast-0.1.0.dist-info/METADATA,sha256=0aRAuDTEJ6TQDu1tEkJQAtCkXbEftJ1CLCukOGdg8p8,3691
|
|
36
|
+
linear_mcp_fast-0.1.0.dist-info/WHEEL,sha256=qELbo2s1Yzl39ZmrAibXA2jjPLUYfnVhUNTlyF1rq0Y,92
|
|
37
|
+
linear_mcp_fast-0.1.0.dist-info/entry_points.txt,sha256=Aa98tAkWz_08mS_SRyfyx0k3PuMBQoMygT88HCKMyWk,57
|
|
38
|
+
linear_mcp_fast-0.1.0.dist-info/top_level.txt,sha256=j-O2BoBpFBpGyTl2V1cp0ZjxZAQwpkweeNxG4BcQ7io,73
|
|
39
|
+
linear_mcp_fast-0.1.0.dist-info/RECORD,,
|