node804-claude-code-mcp 1.0.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.
- node804_claude_code_mcp/__init__.py +3 -0
- node804_claude_code_mcp/config.py +19 -0
- node804_claude_code_mcp/server.py +133 -0
- node804_claude_code_mcp/sessions.py +344 -0
- node804_claude_code_mcp-1.0.0.dist-info/METADATA +214 -0
- node804_claude_code_mcp-1.0.0.dist-info/RECORD +9 -0
- node804_claude_code_mcp-1.0.0.dist-info/WHEEL +4 -0
- node804_claude_code_mcp-1.0.0.dist-info/entry_points.txt +2 -0
- node804_claude_code_mcp-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Configuration for the Claude Code MCP server."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from dotenv import load_dotenv
|
|
7
|
+
|
|
8
|
+
load_dotenv()
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_claude_dir() -> Path:
|
|
12
|
+
override = os.getenv("CLAUDE_DIR")
|
|
13
|
+
if override:
|
|
14
|
+
return Path(override)
|
|
15
|
+
return Path.home() / ".claude"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_projects_dir() -> Path:
|
|
19
|
+
return get_claude_dir() / "projects"
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Claude Code MCP server entry point."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from mcp.server.fastmcp import FastMCP
|
|
7
|
+
|
|
8
|
+
from . import __version__
|
|
9
|
+
from .config import get_projects_dir
|
|
10
|
+
from .sessions import (
|
|
11
|
+
get_session,
|
|
12
|
+
get_session_stats,
|
|
13
|
+
list_projects,
|
|
14
|
+
list_sessions,
|
|
15
|
+
search_sessions,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
logging.basicConfig(level=logging.INFO)
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
mcp = FastMCP("node804_claude_code_mcp")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@mcp.tool()
|
|
25
|
+
async def server_status() -> dict[str, Any]:
|
|
26
|
+
"""Show the current server configuration.
|
|
27
|
+
|
|
28
|
+
Returns the version, the resolved Claude Code data directory, and the
|
|
29
|
+
number of projects found.
|
|
30
|
+
"""
|
|
31
|
+
projects = list_projects()
|
|
32
|
+
return {
|
|
33
|
+
"version": __version__,
|
|
34
|
+
"claude_dir": str(get_projects_dir().parent),
|
|
35
|
+
"projects_dir": str(get_projects_dir()),
|
|
36
|
+
"project_count": len(projects),
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@mcp.tool()
|
|
41
|
+
async def list_claude_projects() -> dict[str, Any]:
|
|
42
|
+
"""List all Claude Code projects in the local session store.
|
|
43
|
+
|
|
44
|
+
Reads from ``~/.claude/projects/`` (or ``CLAUDE_DIR`` if set).
|
|
45
|
+
Returns each project's slug, session count, and last activity timestamp,
|
|
46
|
+
sorted most-recent first.
|
|
47
|
+
"""
|
|
48
|
+
return {
|
|
49
|
+
"projects_dir": str(get_projects_dir()),
|
|
50
|
+
"projects": list_projects(),
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@mcp.tool()
|
|
55
|
+
async def list_claude_sessions(
|
|
56
|
+
project_slug: str = "",
|
|
57
|
+
limit: int = 20,
|
|
58
|
+
) -> dict[str, Any]:
|
|
59
|
+
"""List Claude Code sessions, optionally filtered to a single project.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
project_slug: The project slug (as returned by list_claude_projects).
|
|
63
|
+
Leave empty to list sessions across all projects.
|
|
64
|
+
limit: Maximum number of sessions to return. Defaults to 20.
|
|
65
|
+
"""
|
|
66
|
+
return list_sessions(project_slug or None, limit=limit)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@mcp.tool()
|
|
70
|
+
async def get_claude_session(
|
|
71
|
+
session_id: str,
|
|
72
|
+
project_slug: str,
|
|
73
|
+
include_thinking: bool = False,
|
|
74
|
+
) -> dict[str, Any]:
|
|
75
|
+
"""Get the full conversation transcript from a Claude Code session.
|
|
76
|
+
|
|
77
|
+
Returns user and assistant messages in order, with timestamps and tool
|
|
78
|
+
call names. Internal thinking blocks are excluded unless include_thinking
|
|
79
|
+
is set.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
session_id: The session UUID (as returned by list_claude_sessions).
|
|
83
|
+
project_slug: The project the session belongs to.
|
|
84
|
+
include_thinking: Include assistant thinking blocks. Defaults to False.
|
|
85
|
+
"""
|
|
86
|
+
return get_session(session_id, project_slug, include_thinking=include_thinking)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@mcp.tool()
|
|
90
|
+
async def get_claude_session_stats(
|
|
91
|
+
session_id: str,
|
|
92
|
+
project_slug: str,
|
|
93
|
+
) -> dict[str, Any]:
|
|
94
|
+
"""Get token usage, timing, and message counts for a Claude Code session.
|
|
95
|
+
|
|
96
|
+
Returns input/output token totals, cache read tokens, duration in
|
|
97
|
+
minutes, models used, and the working directory the session was run from.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
session_id: The session UUID.
|
|
101
|
+
project_slug: The project the session belongs to.
|
|
102
|
+
"""
|
|
103
|
+
return get_session_stats(session_id, project_slug)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@mcp.tool()
|
|
107
|
+
async def search_claude_sessions(
|
|
108
|
+
query: str,
|
|
109
|
+
project_slug: str = "",
|
|
110
|
+
limit: int = 20,
|
|
111
|
+
) -> dict[str, Any]:
|
|
112
|
+
"""Search for text across Claude Code session messages.
|
|
113
|
+
|
|
114
|
+
Case-insensitive full-text search over all user and assistant messages.
|
|
115
|
+
Returns matching snippets with surrounding context, session IDs, and
|
|
116
|
+
timestamps.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
query: Text to search for.
|
|
120
|
+
project_slug: Limit search to a single project. Leave empty to
|
|
121
|
+
search all projects.
|
|
122
|
+
limit: Maximum number of hits to return. Defaults to 20.
|
|
123
|
+
"""
|
|
124
|
+
return search_sessions(query, project_slug or None, limit=limit)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def main() -> None:
|
|
128
|
+
logger.info(f"node804-claude-code-mcp v{__version__} starting")
|
|
129
|
+
mcp.run(transport="stdio")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
if __name__ == "__main__":
|
|
133
|
+
main()
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
"""Session and project reading for Claude Code's local JSONL store."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .config import get_projects_dir
|
|
11
|
+
|
|
12
|
+
_MAX_LIMIT = 200
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# ---- Path safety -----------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
def _safe_child(base: Path, *parts: str) -> Path | None:
|
|
18
|
+
"""Resolve a path under base, returning None if it escapes the base directory."""
|
|
19
|
+
try:
|
|
20
|
+
candidate = base.joinpath(*parts).resolve()
|
|
21
|
+
base.resolve() # ensure base itself is resolvable
|
|
22
|
+
candidate.relative_to(base.resolve()) # raises ValueError if outside
|
|
23
|
+
return candidate
|
|
24
|
+
except (ValueError, OSError):
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ---- Project listing -------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
def list_projects() -> list[dict[str, Any]]:
|
|
31
|
+
"""List all projects in ~/.claude/projects/, sorted by most recent activity."""
|
|
32
|
+
projects_dir = get_projects_dir()
|
|
33
|
+
if not projects_dir.is_dir():
|
|
34
|
+
return []
|
|
35
|
+
|
|
36
|
+
results = []
|
|
37
|
+
for entry in projects_dir.iterdir():
|
|
38
|
+
if not entry.is_dir():
|
|
39
|
+
continue
|
|
40
|
+
sessions = _list_session_files(entry)
|
|
41
|
+
timestamps = sorted(
|
|
42
|
+
(s["last_activity"] for s in sessions if s["last_activity"]),
|
|
43
|
+
reverse=True,
|
|
44
|
+
)
|
|
45
|
+
results.append(
|
|
46
|
+
{
|
|
47
|
+
"slug": entry.name,
|
|
48
|
+
"session_count": len(sessions),
|
|
49
|
+
"last_activity": timestamps[0] if timestamps else None,
|
|
50
|
+
}
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
results.sort(key=lambda p: p["last_activity"] or "", reverse=True)
|
|
54
|
+
return results
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ---- Session file listing --------------------------------------------------
|
|
58
|
+
|
|
59
|
+
def _list_session_files(project_dir: Path) -> list[dict[str, Any]]:
|
|
60
|
+
sessions = []
|
|
61
|
+
for f in project_dir.glob("*.jsonl"):
|
|
62
|
+
stat = f.stat()
|
|
63
|
+
sessions.append(
|
|
64
|
+
{
|
|
65
|
+
"session_id": f.stem,
|
|
66
|
+
"project_slug": project_dir.name,
|
|
67
|
+
"last_activity": _mtime_iso(stat),
|
|
68
|
+
"size_bytes": stat.st_size,
|
|
69
|
+
}
|
|
70
|
+
)
|
|
71
|
+
sessions.sort(key=lambda s: s["last_activity"] or "", reverse=True)
|
|
72
|
+
return sessions
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def list_sessions(project_slug: str | None = None, limit: int = 20) -> dict[str, Any]:
|
|
76
|
+
limit = min(limit, _MAX_LIMIT)
|
|
77
|
+
projects_dir = get_projects_dir()
|
|
78
|
+
sessions: list[dict[str, Any]] = []
|
|
79
|
+
|
|
80
|
+
if project_slug:
|
|
81
|
+
project_dir = _safe_child(projects_dir, project_slug)
|
|
82
|
+
if not project_dir or not project_dir.is_dir():
|
|
83
|
+
return {"error": f"Project not found: {project_slug}"}
|
|
84
|
+
sessions = _list_session_files(project_dir)
|
|
85
|
+
else:
|
|
86
|
+
if not projects_dir.is_dir():
|
|
87
|
+
return {"total": 0, "shown": 0, "sessions": []}
|
|
88
|
+
for entry in projects_dir.iterdir():
|
|
89
|
+
if entry.is_dir():
|
|
90
|
+
sessions.extend(_list_session_files(entry))
|
|
91
|
+
sessions.sort(key=lambda s: s["last_activity"] or "", reverse=True)
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
"total": len(sessions),
|
|
95
|
+
"shown": min(len(sessions), limit),
|
|
96
|
+
"sessions": sessions[:limit],
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# ---- JSONL reading ---------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
def _read_jsonl(path: Path) -> list[dict[str, Any]]:
|
|
103
|
+
records = []
|
|
104
|
+
try:
|
|
105
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
106
|
+
line = line.strip()
|
|
107
|
+
if not line:
|
|
108
|
+
continue
|
|
109
|
+
try:
|
|
110
|
+
records.append(json.loads(line))
|
|
111
|
+
except json.JSONDecodeError:
|
|
112
|
+
pass
|
|
113
|
+
except OSError:
|
|
114
|
+
pass
|
|
115
|
+
return records
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _extract_text(content: str | list[dict] | None) -> str:
|
|
119
|
+
if not content:
|
|
120
|
+
return ""
|
|
121
|
+
if isinstance(content, str):
|
|
122
|
+
return content
|
|
123
|
+
return "\n".join(
|
|
124
|
+
b.get("text", "") for b in content if b.get("type") == "text"
|
|
125
|
+
).strip()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _extract_tool_calls(content: list[dict]) -> list[str]:
|
|
129
|
+
return [b["name"] for b in content if b.get("type") == "tool_use" and b.get("name")]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ---- Public session API ----------------------------------------------------
|
|
133
|
+
|
|
134
|
+
def get_session(
|
|
135
|
+
session_id: str,
|
|
136
|
+
project_slug: str,
|
|
137
|
+
include_thinking: bool = False,
|
|
138
|
+
) -> dict[str, Any]:
|
|
139
|
+
projects_dir = get_projects_dir()
|
|
140
|
+
path = _safe_child(projects_dir, project_slug, f"{session_id}.jsonl")
|
|
141
|
+
if not path or not path.is_file():
|
|
142
|
+
return {"error": f"Session not found: {session_id}"}
|
|
143
|
+
|
|
144
|
+
messages = []
|
|
145
|
+
for rec in _read_jsonl(path):
|
|
146
|
+
rtype = rec.get("type")
|
|
147
|
+
|
|
148
|
+
if rtype == "user":
|
|
149
|
+
text = _extract_text(rec.get("message", {}).get("content"))
|
|
150
|
+
if not text:
|
|
151
|
+
continue
|
|
152
|
+
messages.append(
|
|
153
|
+
{
|
|
154
|
+
"uuid": rec.get("uuid"),
|
|
155
|
+
"role": "user",
|
|
156
|
+
"timestamp": rec.get("timestamp"),
|
|
157
|
+
"text": text,
|
|
158
|
+
}
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
elif rtype == "assistant":
|
|
162
|
+
msg = rec.get("message", {})
|
|
163
|
+
content = msg.get("content") or []
|
|
164
|
+
|
|
165
|
+
text_parts = [b.get("text", "") for b in content if b.get("type") == "text"]
|
|
166
|
+
text = "\n".join(text_parts).strip()
|
|
167
|
+
|
|
168
|
+
if include_thinking:
|
|
169
|
+
thinking_parts = [
|
|
170
|
+
f"<thinking>\n{b['thinking']}\n</thinking>"
|
|
171
|
+
for b in content
|
|
172
|
+
if b.get("type") == "thinking" and b.get("thinking")
|
|
173
|
+
]
|
|
174
|
+
if thinking_parts:
|
|
175
|
+
text = "\n".join(thinking_parts) + ("\n\n" + text if text else "")
|
|
176
|
+
|
|
177
|
+
tool_calls = _extract_tool_calls(content)
|
|
178
|
+
if not text and not tool_calls:
|
|
179
|
+
continue
|
|
180
|
+
|
|
181
|
+
messages.append(
|
|
182
|
+
{
|
|
183
|
+
"uuid": rec.get("uuid"),
|
|
184
|
+
"role": "assistant",
|
|
185
|
+
"timestamp": rec.get("timestamp"),
|
|
186
|
+
"text": text,
|
|
187
|
+
"model": msg.get("model"),
|
|
188
|
+
"tool_calls": tool_calls or None,
|
|
189
|
+
}
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
"session_id": session_id,
|
|
194
|
+
"project_slug": project_slug,
|
|
195
|
+
"message_count": len(messages),
|
|
196
|
+
"messages": messages,
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def get_session_stats(session_id: str, project_slug: str) -> dict[str, Any]:
|
|
201
|
+
projects_dir = get_projects_dir()
|
|
202
|
+
path = _safe_child(projects_dir, project_slug, f"{session_id}.jsonl")
|
|
203
|
+
if not path or not path.is_file():
|
|
204
|
+
return {"error": f"Session not found: {session_id}"}
|
|
205
|
+
|
|
206
|
+
user_count = 0
|
|
207
|
+
assistant_count = 0
|
|
208
|
+
total_input = 0
|
|
209
|
+
total_output = 0
|
|
210
|
+
cache_read = 0
|
|
211
|
+
first_ts = None
|
|
212
|
+
last_ts = None
|
|
213
|
+
models: set[str] = set()
|
|
214
|
+
cwd = None
|
|
215
|
+
|
|
216
|
+
for rec in _read_jsonl(path):
|
|
217
|
+
rtype = rec.get("type")
|
|
218
|
+
if rtype not in ("user", "assistant"):
|
|
219
|
+
continue
|
|
220
|
+
|
|
221
|
+
ts = rec.get("timestamp")
|
|
222
|
+
if ts:
|
|
223
|
+
if not first_ts or ts < first_ts:
|
|
224
|
+
first_ts = ts
|
|
225
|
+
if not last_ts or ts > last_ts:
|
|
226
|
+
last_ts = ts
|
|
227
|
+
|
|
228
|
+
if rtype == "user":
|
|
229
|
+
user_count += 1
|
|
230
|
+
if not cwd:
|
|
231
|
+
cwd = rec.get("cwd")
|
|
232
|
+
elif rtype == "assistant":
|
|
233
|
+
assistant_count += 1
|
|
234
|
+
msg = rec.get("message", {})
|
|
235
|
+
if msg.get("model"):
|
|
236
|
+
models.add(msg["model"])
|
|
237
|
+
usage = msg.get("usage") or {}
|
|
238
|
+
total_input += usage.get("input_tokens", 0)
|
|
239
|
+
total_output += usage.get("output_tokens", 0)
|
|
240
|
+
cache_read += usage.get("cache_read_input_tokens", 0)
|
|
241
|
+
|
|
242
|
+
duration_minutes = None
|
|
243
|
+
if first_ts and last_ts and first_ts != last_ts:
|
|
244
|
+
fmt = "%Y-%m-%dT%H:%M:%S.%fZ"
|
|
245
|
+
try:
|
|
246
|
+
t0 = datetime.strptime(first_ts[:26] + "Z", fmt).replace(tzinfo=timezone.utc)
|
|
247
|
+
t1 = datetime.strptime(last_ts[:26] + "Z", fmt).replace(tzinfo=timezone.utc)
|
|
248
|
+
duration_minutes = round((t1 - t0).total_seconds() / 60)
|
|
249
|
+
except ValueError:
|
|
250
|
+
pass
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
"session_id": session_id,
|
|
254
|
+
"project_slug": project_slug,
|
|
255
|
+
"message_count": user_count + assistant_count,
|
|
256
|
+
"user_messages": user_count,
|
|
257
|
+
"assistant_messages": assistant_count,
|
|
258
|
+
"total_input_tokens": total_input,
|
|
259
|
+
"total_output_tokens": total_output,
|
|
260
|
+
"cache_read_tokens": cache_read,
|
|
261
|
+
"first_message": first_ts,
|
|
262
|
+
"last_message": last_ts,
|
|
263
|
+
"duration_minutes": duration_minutes,
|
|
264
|
+
"models_used": sorted(models),
|
|
265
|
+
"cwd": cwd,
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def search_sessions(
|
|
270
|
+
query: str,
|
|
271
|
+
project_slug: str | None = None,
|
|
272
|
+
limit: int = 20,
|
|
273
|
+
) -> dict[str, Any]:
|
|
274
|
+
limit = min(limit, _MAX_LIMIT)
|
|
275
|
+
projects_dir = get_projects_dir()
|
|
276
|
+
lower = query.lower()
|
|
277
|
+
hits: list[dict[str, Any]] = []
|
|
278
|
+
|
|
279
|
+
if project_slug:
|
|
280
|
+
project_dir = _safe_child(projects_dir, project_slug)
|
|
281
|
+
if not project_dir or not project_dir.is_dir():
|
|
282
|
+
return {"error": f"Project not found: {project_slug}"}
|
|
283
|
+
slugs = [project_slug]
|
|
284
|
+
else:
|
|
285
|
+
if not projects_dir.is_dir():
|
|
286
|
+
return {"query": query, "total": 0, "shown": 0, "hits": []}
|
|
287
|
+
slugs = [e.name for e in projects_dir.iterdir() if e.is_dir()]
|
|
288
|
+
|
|
289
|
+
for slug in slugs:
|
|
290
|
+
if len(hits) >= limit:
|
|
291
|
+
break
|
|
292
|
+
slug_dir = _safe_child(projects_dir, slug)
|
|
293
|
+
if not slug_dir:
|
|
294
|
+
continue
|
|
295
|
+
for session in _list_session_files(slug_dir):
|
|
296
|
+
if len(hits) >= limit:
|
|
297
|
+
break
|
|
298
|
+
path = slug_dir / f"{session['session_id']}.jsonl"
|
|
299
|
+
for rec in _read_jsonl(path):
|
|
300
|
+
rtype = rec.get("type")
|
|
301
|
+
if rtype not in ("user", "assistant"):
|
|
302
|
+
continue
|
|
303
|
+
|
|
304
|
+
full_text = _extract_text(rec.get("message", {}).get("content") or [])
|
|
305
|
+
uuid = rec.get("uuid", "")
|
|
306
|
+
|
|
307
|
+
idx = full_text.lower().find(lower)
|
|
308
|
+
if idx == -1:
|
|
309
|
+
continue
|
|
310
|
+
|
|
311
|
+
ctx = 200
|
|
312
|
+
start = max(0, idx - ctx)
|
|
313
|
+
end = min(len(full_text), idx + len(query) + ctx)
|
|
314
|
+
snippet = full_text[start:end].strip()
|
|
315
|
+
if start > 0:
|
|
316
|
+
snippet = "…" + snippet
|
|
317
|
+
if end < len(full_text):
|
|
318
|
+
snippet = snippet + "…"
|
|
319
|
+
|
|
320
|
+
hits.append(
|
|
321
|
+
{
|
|
322
|
+
"session_id": session["session_id"],
|
|
323
|
+
"project_slug": slug,
|
|
324
|
+
"message_uuid": uuid,
|
|
325
|
+
"role": rtype,
|
|
326
|
+
"timestamp": rec.get("timestamp"),
|
|
327
|
+
"snippet": snippet,
|
|
328
|
+
}
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
return {
|
|
332
|
+
"query": query,
|
|
333
|
+
"total": len(hits),
|
|
334
|
+
"shown": min(len(hits), limit),
|
|
335
|
+
"hits": hits[:limit],
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
# ---- Helpers ---------------------------------------------------------------
|
|
340
|
+
|
|
341
|
+
def _mtime_iso(stat: Any) -> str:
|
|
342
|
+
return datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).strftime(
|
|
343
|
+
"%Y-%m-%dT%H:%M:%SZ"
|
|
344
|
+
)
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: node804-claude-code-mcp
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: MCP server for reading Claude Code session history from the local filesystem
|
|
5
|
+
Project-URL: Homepage, https://github.com/Node804/node804-claude-code-mcp
|
|
6
|
+
Project-URL: Repository, https://github.com/Node804/node804-claude-code-mcp
|
|
7
|
+
Project-URL: Issues, https://github.com/Node804/node804-claude-code-mcp/issues
|
|
8
|
+
Author-email: Michael Pope <me@michaelpope.cv>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: claude,claude-code,knowledge-management,mcp,model-context-protocol,pkm,session-history
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
|
+
Classifier: Topic :: Utilities
|
|
21
|
+
Requires-Python: >=3.11
|
|
22
|
+
Requires-Dist: mcp[cli]>=1.3.0
|
|
23
|
+
Requires-Dist: python-dotenv>=1.0.0
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# node804-claude-code-mcp
|
|
30
|
+
|
|
31
|
+
[](https://pypi.org/project/node804-claude-code-mcp/)
|
|
32
|
+
[](https://pypi.org/project/node804-claude-code-mcp/)
|
|
33
|
+
[](LICENSE)
|
|
34
|
+
[](https://github.com/Node804/node804-claude-code-mcp/actions/workflows/ci.yml)
|
|
35
|
+
|
|
36
|
+
MCP server for reading Claude Code session history from the local filesystem.
|
|
37
|
+
Exposes project and session data to any MCP-compatible client — Claude Cowork, Claude Chat, scheduled agents, or your own tooling.
|
|
38
|
+
|
|
39
|
+
Reads directly from `~/.claude/projects/` — no external dependencies, no network calls, no API keys.
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## Why this exists
|
|
44
|
+
|
|
45
|
+
Claude Code stores every session as a local JSONL file containing the full conversation: user messages, assistant responses, tool calls, token counts, and timing. That history is rich and detailed, but it's locked inside the CLI with no way to query it from other tools.
|
|
46
|
+
|
|
47
|
+
This server opens it up.
|
|
48
|
+
|
|
49
|
+
### Use cases
|
|
50
|
+
|
|
51
|
+
**Personal Knowledge Management and daily logging**
|
|
52
|
+
Claude Code sessions are a detailed record of decisions made, problems solved, and code written. This server lets a Cowork or Chat session pull that raw material into a daily note, work log, or PKM entry automatically — capturing not just *what* changed but the reasoning behind it, without any manual copy-paste.
|
|
53
|
+
|
|
54
|
+
**Cross-session continuity**
|
|
55
|
+
Starting a new Claude Code session on a project that was last touched weeks ago means re-explaining context from scratch. With this server, a fresh session can search prior conversations for relevant background — design decisions, dead ends already explored, environment quirks — before writing a single line of code.
|
|
56
|
+
|
|
57
|
+
**Standup and progress reporting**
|
|
58
|
+
`search_claude_sessions` and `get_claude_session_stats` give enough signal to reconstruct what was worked on across a day or week. A scheduled agent can draft standup notes or weekly summaries without you keeping a separate activity log.
|
|
59
|
+
|
|
60
|
+
**Token and time auditing**
|
|
61
|
+
`get_claude_session_stats` returns per-session input/output token counts, cache hit rates, and duration. Useful for understanding which projects are consuming the most AI time, or for back-of-envelope cost tracking across a team.
|
|
62
|
+
|
|
63
|
+
**Recovering decisions and rationale**
|
|
64
|
+
The assistant messages in a session contain reasoning that never makes it into git history or documentation. When a colleague asks "why did we pick X over Y?", a search across sessions is often faster than reading commits — and captures the full deliberation, not just the outcome.
|
|
65
|
+
|
|
66
|
+
**Handoff and onboarding**
|
|
67
|
+
A new team member or a handoff to another AI session can be primed with the actual conversation history from a project rather than a manually written summary that may already be stale.
|
|
68
|
+
|
|
69
|
+
**Research and spike synthesis**
|
|
70
|
+
Exploratory sessions — spiking on a library, investigating an incident, prototyping an approach — produce valuable findings that are easy to lose. Searching across those sessions lets later work build on earlier exploration rather than repeat it.
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## Installation
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
pip install node804-claude-code-mcp
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Or install from source:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
git clone https://github.com/Node804/node804-claude-code-mcp
|
|
84
|
+
cd node804-claude-code-mcp
|
|
85
|
+
pip install -e .
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
## Setup
|
|
91
|
+
|
|
92
|
+
### Claude Desktop / Claude Chat
|
|
93
|
+
|
|
94
|
+
Add to `claude_desktop_config.json`:
|
|
95
|
+
|
|
96
|
+
**Windows:** `%APPDATA%\Claude\claude_desktop_config.json`
|
|
97
|
+
**macOS:** `~/Library/Application Support/Claude/claude_desktop_config.json`
|
|
98
|
+
|
|
99
|
+
```json
|
|
100
|
+
{
|
|
101
|
+
"mcpServers": {
|
|
102
|
+
"claude-code-logs": {
|
|
103
|
+
"command": "python",
|
|
104
|
+
"args": ["-m", "node804_claude_code_mcp.server"]
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Restart Claude Desktop after saving.
|
|
111
|
+
|
|
112
|
+
### Claude Code (settings.json)
|
|
113
|
+
|
|
114
|
+
Add to `.claude/settings.json` in your project, or to the global `~/.claude/settings.json`:
|
|
115
|
+
|
|
116
|
+
```json
|
|
117
|
+
{
|
|
118
|
+
"mcpServers": {
|
|
119
|
+
"claude-code-logs": {
|
|
120
|
+
"command": "python",
|
|
121
|
+
"args": ["-m", "node804_claude_code_mcp.server"]
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
## Environment variables
|
|
130
|
+
|
|
131
|
+
| Variable | Default | Description |
|
|
132
|
+
|-------------|-------------|--------------------------------------------|
|
|
133
|
+
| `CLAUDE_DIR` | `~/.claude` | Override the Claude Code data directory |
|
|
134
|
+
|
|
135
|
+
---
|
|
136
|
+
|
|
137
|
+
## Tools
|
|
138
|
+
|
|
139
|
+
### `server_status`
|
|
140
|
+
Show the current server version and resolved data directory.
|
|
141
|
+
|
|
142
|
+
---
|
|
143
|
+
|
|
144
|
+
### `list_claude_projects`
|
|
145
|
+
List all Claude Code projects in the local session store, sorted by most recent activity.
|
|
146
|
+
|
|
147
|
+
**Returns:** project slug, session count, last activity timestamp.
|
|
148
|
+
|
|
149
|
+
---
|
|
150
|
+
|
|
151
|
+
### `list_claude_sessions`
|
|
152
|
+
List sessions for a specific project or all projects.
|
|
153
|
+
|
|
154
|
+
| Argument | Type | Default | Description |
|
|
155
|
+
|---------------|--------|---------|-------------------------------------|
|
|
156
|
+
| `project_slug` | string | — | Filter to one project (optional) |
|
|
157
|
+
| `limit` | int | 20 | Max sessions to return |
|
|
158
|
+
|
|
159
|
+
---
|
|
160
|
+
|
|
161
|
+
### `get_claude_session`
|
|
162
|
+
Get the full conversation transcript from a session. Returns user and assistant messages in order, with timestamps and tool call names.
|
|
163
|
+
|
|
164
|
+
| Argument | Type | Default | Description |
|
|
165
|
+
|-------------------|---------|---------|----------------------------------------|
|
|
166
|
+
| `session_id` | string | — | Session UUID |
|
|
167
|
+
| `project_slug` | string | — | Project the session belongs to |
|
|
168
|
+
| `include_thinking` | bool | false | Include assistant thinking blocks |
|
|
169
|
+
|
|
170
|
+
---
|
|
171
|
+
|
|
172
|
+
### `get_claude_session_stats`
|
|
173
|
+
Token usage, timing, and message counts for a session.
|
|
174
|
+
|
|
175
|
+
| Argument | Type | Description |
|
|
176
|
+
|---------------|--------|--------------------|
|
|
177
|
+
| `session_id` | string | Session UUID |
|
|
178
|
+
| `project_slug`| string | Project slug |
|
|
179
|
+
|
|
180
|
+
**Returns:** input/output tokens, cache read tokens, duration in minutes, models used, working directory.
|
|
181
|
+
|
|
182
|
+
---
|
|
183
|
+
|
|
184
|
+
### `search_claude_sessions`
|
|
185
|
+
Case-insensitive full-text search across all session messages. Returns matching snippets with surrounding context.
|
|
186
|
+
|
|
187
|
+
| Argument | Type | Default | Description |
|
|
188
|
+
|---------------|--------|---------|--------------------------------------|
|
|
189
|
+
| `query` | string | — | Text to search for |
|
|
190
|
+
| `project_slug`| string | — | Limit to one project (optional) |
|
|
191
|
+
| `limit` | int | 20 | Max hits to return |
|
|
192
|
+
|
|
193
|
+
---
|
|
194
|
+
|
|
195
|
+
## Known limitations
|
|
196
|
+
|
|
197
|
+
Session files are read linearly from top to bottom. Claude Code's JSONL format is actually a tree — every record links to its parent via `parentUuid`, which means a session can contain forks (abandoned tool call branches, retries, compaction summaries). Linear reading is correct for the vast majority of normal interactive sessions, but a forked session could include abandoned branches in the output. Fork-aware traversal that follows the primary thread and discards dead branches is a potential future improvement.
|
|
198
|
+
|
|
199
|
+
---
|
|
200
|
+
|
|
201
|
+
## Data locations
|
|
202
|
+
|
|
203
|
+
Claude Code stores session data at:
|
|
204
|
+
|
|
205
|
+
- **Windows:** `%USERPROFILE%\.claude\projects\`
|
|
206
|
+
- **macOS / Linux:** `~/.claude/projects/`
|
|
207
|
+
|
|
208
|
+
Each project directory contains one `.jsonl` file per session. Records include `user` messages, `assistant` messages (with tool calls and token usage), and internal control messages.
|
|
209
|
+
|
|
210
|
+
---
|
|
211
|
+
|
|
212
|
+
## License
|
|
213
|
+
|
|
214
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
node804_claude_code_mcp/__init__.py,sha256=S2Co1iNFfn48iS225htCcFgqT0HdmrUeTY4qvNUzAkg,97
|
|
2
|
+
node804_claude_code_mcp/config.py,sha256=Cxb1K-PsrwSJ94-0qSXe_7kuQRYa9ivku6AWLMSdOQA,363
|
|
3
|
+
node804_claude_code_mcp/server.py,sha256=rv_CHLgCZ3cQ_JDCJEnsU-nYpvxqOgycfea-kXw-FgA,3816
|
|
4
|
+
node804_claude_code_mcp/sessions.py,sha256=OCmZ0ULOwo9XfWuABONFkHqz9EMb8_RqkGp0HXaFlBI,11084
|
|
5
|
+
node804_claude_code_mcp-1.0.0.dist-info/METADATA,sha256=RFr7dgYTucyKbzQaN5PSn9Lw89xURAF9U4ymPESd9jc,8833
|
|
6
|
+
node804_claude_code_mcp-1.0.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
7
|
+
node804_claude_code_mcp-1.0.0.dist-info/entry_points.txt,sha256=SIea3MITRkCAgO_qYHd-dvwDJc4O7ZblFvrjLp8gJMI,80
|
|
8
|
+
node804_claude_code_mcp-1.0.0.dist-info/licenses/LICENSE,sha256=MWZG7mvTKQAmF-bl5ZJ9VkzcpQy-ZPgzRoMe_rKekTc,1069
|
|
9
|
+
node804_claude_code_mcp-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Michael Pope
|
|
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.
|