heapspy-mcp 0.1.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.
@@ -0,0 +1,3 @@
1
+ """MCP server that gives coding agents access to heapspy."""
2
+
3
+ __version__ = "0.1.1"
@@ -0,0 +1,167 @@
1
+ """Command-line entry point: ``heapspy-mcp``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import logging
7
+ import sys
8
+
9
+ from . import __version__
10
+ from . import config as cpu_config # CPU profiling (py-spy)
11
+ from .config import (
12
+ DEFAULT_CAPTURE_TIMEOUT,
13
+ DEFAULT_TIMEOUT,
14
+ ENV_ALLOW,
15
+ ENV_BIN,
16
+ ENV_CAPTURE_TIMEOUT,
17
+ ENV_MAX_RSS,
18
+ ENV_MAX_STR,
19
+ ENV_SNAPSHOT_DIR,
20
+ ENV_TIMEOUT,
21
+ Config,
22
+ ConfigError,
23
+ add_diagnostics_arguments,
24
+ default_snapshot_dir,
25
+ diagnostics_from_args,
26
+ )
27
+
28
+
29
+ def build_parser() -> argparse.ArgumentParser:
30
+ p = argparse.ArgumentParser(
31
+ prog="heapspy-mcp",
32
+ description="MCP server exposing heapspy heap snapshots and analysis to coding agents.",
33
+ )
34
+ p.add_argument("--version", action="version", version=f"heapspy-mcp {__version__}")
35
+ p.add_argument(
36
+ "--transport",
37
+ choices=["stdio", "streamable-http"],
38
+ default="stdio",
39
+ help="MCP transport (default: stdio)",
40
+ )
41
+ p.add_argument("--host", default="127.0.0.1", help="streamable-http bind address")
42
+ p.add_argument("--port", type=int, default=8000, help="streamable-http port")
43
+ p.add_argument(
44
+ "--heapspy-bin",
45
+ metavar="PATH",
46
+ help=f"heapspy executable (else ${ENV_BIN}, else 'heapspy' on PATH)",
47
+ )
48
+ p.add_argument(
49
+ "--allow",
50
+ action="append",
51
+ metavar="PATTERN",
52
+ help=(
53
+ "allow live capture of processes matching PATTERN: a glob on the process name, "
54
+ "executable name or command line, or pid:N. Repeatable; comma-separated lists "
55
+ f"accepted. Merged with ${ENV_ALLOW}. Default: none (live capture disabled)."
56
+ ),
57
+ )
58
+ p.add_argument(
59
+ "--max-str",
60
+ type=int,
61
+ metavar="N",
62
+ help=(
63
+ "default per-string cap, in characters, for capture_contents=true when the "
64
+ f"tool call gives no max_str (also ${ENV_MAX_STR}; default 1024)"
65
+ ),
66
+ )
67
+ p.add_argument(
68
+ "--max-rss",
69
+ metavar="SIZE",
70
+ help=f"refuse live capture above this RSS, e.g. 8G (also ${ENV_MAX_RSS}; default 8G)",
71
+ )
72
+ p.add_argument(
73
+ "--snapshot-dir",
74
+ metavar="DIR",
75
+ help=f"where snapshots are written (also ${ENV_SNAPSHOT_DIR}; "
76
+ f"default {default_snapshot_dir()})",
77
+ )
78
+ p.add_argument(
79
+ "--timeout",
80
+ type=float,
81
+ metavar="SECONDS",
82
+ help=f"timeout for analysis commands (also ${ENV_TIMEOUT}; default {DEFAULT_TIMEOUT:g})",
83
+ )
84
+ p.add_argument(
85
+ "--capture-timeout",
86
+ type=float,
87
+ metavar="SECONDS",
88
+ help=f"timeout for heapspy dump (also ${ENV_CAPTURE_TIMEOUT}; "
89
+ f"default {DEFAULT_CAPTURE_TIMEOUT:g})",
90
+ )
91
+ add_diagnostics_arguments(p)
92
+ # --- CPU profiling (py-spy) ---
93
+ p.add_argument(
94
+ "--py-spy-bin",
95
+ metavar="PATH",
96
+ help=f"py-spy executable for the CPU tools (else ${cpu_config.ENV_PY_SPY_BIN}, "
97
+ "else 'py-spy' on PATH or next to this Python)",
98
+ )
99
+ p.add_argument(
100
+ "--cpu-max-duration",
101
+ type=int,
102
+ metavar="SECONDS",
103
+ help=f"longest CPU profile a tool may record (also ${cpu_config.ENV_CPU_MAX_DURATION}; "
104
+ f"default {cpu_config.DEFAULT_CPU_MAX_DURATION})",
105
+ )
106
+ # --- end CPU profiling ---
107
+ return p
108
+
109
+
110
+ def main(argv: list[str] | None = None) -> int:
111
+ args = build_parser().parse_args(argv)
112
+ handler = logging.StreamHandler(sys.stderr)
113
+ handler.setFormatter(logging.Formatter("heapspy-mcp: %(levelname)s %(message)s"))
114
+ log = logging.getLogger("heapspy_mcp")
115
+ log.addHandler(handler)
116
+ log.setLevel(logging.INFO)
117
+ log.propagate = False
118
+
119
+ try:
120
+ config = Config.from_sources(
121
+ heapspy_bin=args.heapspy_bin,
122
+ snapshot_dir=args.snapshot_dir,
123
+ allow=args.allow,
124
+ max_str=args.max_str,
125
+ max_rss=args.max_rss,
126
+ timeout=args.timeout,
127
+ capture_timeout=args.capture_timeout,
128
+ )
129
+ config.diagnostics = diagnostics_from_args(args)
130
+ cpu_config.load_cpu_settings(
131
+ config, py_spy_bin=args.py_spy_bin, cpu_max_duration=args.cpu_max_duration
132
+ )
133
+ except ConfigError as e:
134
+ print(f"heapspy-mcp: error: {e}", file=sys.stderr)
135
+ return 2
136
+
137
+ if config.heapspy_bin is None:
138
+ log.warning(
139
+ "heapspy executable not found (no --heapspy-bin, no $%s, not on PATH); "
140
+ "analysis and capture tools will return binary_not_found",
141
+ ENV_BIN,
142
+ )
143
+ log.info(
144
+ "heapspy=%s snapshot_dir=%s allow=%s default_max_str=%s max_rss=%d",
145
+ config.heapspy_bin,
146
+ config.snapshot_dir,
147
+ config.allow or "(none: live capture disabled)",
148
+ config.max_str,
149
+ config.max_rss_bytes,
150
+ )
151
+
152
+ if config.py_spy_bin is None:
153
+ log.warning("py-spy not found; the CPU tools will return py_spy_not_found")
154
+ log.info("py_spy=%s cpu_max_duration=%ds", config.py_spy_bin, config.cpu_max_duration)
155
+
156
+ from .server import build_server
157
+
158
+ server = build_server(config)
159
+ if args.transport == "stdio":
160
+ server.run("stdio")
161
+ else:
162
+ server.run("streamable-http", host=args.host, port=args.port)
163
+ return 0
164
+
165
+
166
+ if __name__ == "__main__":
167
+ sys.exit(main())
@@ -0,0 +1,347 @@
1
+ """Bounded summaries of heapspy allocation data (``.hpya`` profiles).
2
+
3
+ Two sources:
4
+
5
+ - ``analyze SNAP --allocations FILE --json``: the ``allocations`` block
6
+ (recording header, coverage, ``top_stacks``) and ``allocated_at`` on each
7
+ top retainer; ``query --type ... --allocations`` fills ``allocated_at`` on
8
+ instances. See docs/json-schema.md, "Allocations".
9
+ - ``flamegraph --allocations FILE --folded``: the only output heapspy has for
10
+ a profile without a snapshot. It gives estimated bytes per stack and
11
+ nothing else (no sample counts, event kinds or recording header).
12
+
13
+ Frames are innermost first everywhere in the output, as in the JSON report.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import re
19
+ from typing import Any
20
+
21
+ from .summarize import MAX_NAME, clip, notes, with_human
22
+
23
+ MAX_FRAMES = 6
24
+ MAX_STACK_HINTS = 3
25
+ MAX_ALLOCATED_AT_STACKS = 3
26
+ MAX_SUMMARY_STACKS = 5
27
+ MAX_FOLDED_LINES = 100_000
28
+ MAX_LOCATION = 160
29
+
30
+ CAVEATS = (
31
+ "Sampled, not complete: only arena, pool and large-object events made while recording "
32
+ "are sampled. Blocks reused from freed memory, and objects allocated before recording "
33
+ "started, have no stack.",
34
+ "Arena and pool links are approximate: they name the most recent growth event that "
35
+ "created the memory, not necessarily the call that allocated the object. "
36
+ "exact_linked_bytes counts links through large-object samples, which are exact: the "
37
+ "object is the sampled allocation itself. Objects on memory a sampled large block "
38
+ "left behind, which they do not match, are not linked (coverage.unlinked_reused_*).",
39
+ "estimated_bytes is the sum of sample weights over the recording window: an estimate "
40
+ "of bytes allocated through sampled events, not of bytes still alive. linked_bytes is "
41
+ "what is still alive in the snapshot.",
42
+ )
43
+
44
+ _IDENT_PATH_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*(/[A-Za-z_][A-Za-z0-9_]*)*\.py$")
45
+ _FOLDED_FRAME_RE = re.compile(r"^(?P<function>.*) \((?P<location>[^()]*)\)$")
46
+
47
+
48
+ # -- frames and hints ----------------------------------------------------------------
49
+
50
+
51
+ def _module_guess(file: str) -> str | None:
52
+ """``app/session.py`` -> ``app.session`` for a relative path of identifiers."""
53
+ path = file.replace("\\", "/").removeprefix("./")
54
+ if not _IDENT_PATH_RE.match(path):
55
+ return None
56
+ module = path.removesuffix(".py").replace("/", ".")
57
+ return module.removesuffix(".__init__") or None
58
+
59
+
60
+ def qualname_guess(function: str | None, file: str | None) -> str | None:
61
+ """A ``module.qualname`` for ``map_to_source`` from a frame, when one can be guessed.
62
+
63
+ Only for relative ``co_filename`` values made of identifiers; the module
64
+ is then the path. For an absolute filename the repository root is not
65
+ known, so there is no guess: read the file at the line instead.
66
+ """
67
+ if not isinstance(file, str) or not isinstance(function, str):
68
+ return None
69
+ module = _module_guess(file)
70
+ if module is None:
71
+ return None
72
+ if function == "<module>":
73
+ return module
74
+ parts = function.split(".")
75
+ if not all(p.isidentifier() or p == "<locals>" for p in parts) or parts[0] == "<locals>":
76
+ return None
77
+ return f"{module}.{function}"
78
+
79
+
80
+ def clip_left(text: Any, limit: int = MAX_LOCATION) -> Any:
81
+ """Clip a path from the left: the file name and line are the useful end."""
82
+ if not isinstance(text, str) or len(text) <= limit:
83
+ return text
84
+ return "..." + text[-(limit - 3) :]
85
+
86
+
87
+ def _frame(frame: dict[str, Any]) -> dict[str, Any]:
88
+ line = frame.get("line")
89
+ approximate = frame.get("approximate")
90
+ return {
91
+ "function": clip(frame.get("function"), MAX_NAME),
92
+ "file": clip_left(frame.get("file")),
93
+ "line": line if isinstance(line, int) else None,
94
+ "location": clip_left(frame.get("location")),
95
+ # None in folded output, which does not carry the flag.
96
+ "approximate": approximate if isinstance(approximate, bool) else None,
97
+ }
98
+
99
+
100
+ def stack_source_hints(frames: list[dict[str, Any]]) -> list[dict[str, Any]]:
101
+ """Where to look, innermost frame first: ``file:line`` and a qualname guess.
102
+
103
+ The first hint is the allocating function itself; the next ones are its
104
+ callers, which are often where the decision to keep the object is made.
105
+ """
106
+ hints: list[dict[str, Any]] = []
107
+ for f in frames:
108
+ if len(hints) >= MAX_STACK_HINTS:
109
+ break
110
+ file, line = f.get("file"), f.get("line")
111
+ if not isinstance(file, str) or not file or file.startswith("<"):
112
+ continue
113
+ hints.append(
114
+ {
115
+ "location": f.get("location") or f"{file}:{line if line is not None else '?'}",
116
+ "file": file,
117
+ "line": line,
118
+ "function": f.get("function"),
119
+ "qualname_guess": qualname_guess(f.get("function"), file),
120
+ }
121
+ )
122
+ return hints
123
+
124
+
125
+ def _frames(raw: Any) -> tuple[list[dict[str, Any]], int]:
126
+ frames = [f for f in raw or [] if isinstance(f, dict)]
127
+ return [_frame(f) for f in frames[:MAX_FRAMES]], len(frames)
128
+
129
+
130
+ def _stack_common(frames_raw: Any) -> dict[str, Any]:
131
+ frames, total = _frames(frames_raw)
132
+ out: dict[str, Any] = {
133
+ "allocated_in": frames[0]["location"] if frames else None,
134
+ "frames": frames,
135
+ "frames_total": total,
136
+ "source_hints": stack_source_hints(frames),
137
+ }
138
+ return out
139
+
140
+
141
+ # -- analyze / query reports ---------------------------------------------------------
142
+
143
+
144
+ def summarize_top_stack(stack: dict[str, Any]) -> dict[str, Any]:
145
+ item: dict[str, Any] = {
146
+ "rank": stack.get("rank"),
147
+ "estimated_bytes": stack.get("estimated_bytes"),
148
+ "samples": stack.get("samples"),
149
+ "events": list(stack.get("events") or [])[:8],
150
+ "linked_bytes": stack.get("linked_bytes"),
151
+ "exact_linked_bytes": stack.get("exact_linked_bytes"),
152
+ "approximate": stack.get("approximate"),
153
+ **_stack_common(stack.get("frames")),
154
+ }
155
+ return with_human(item, "estimated_bytes", "linked_bytes", "exact_linked_bytes")
156
+
157
+
158
+ def summarize_allocated_at(
159
+ at: dict[str, Any] | None, max_stacks: int = MAX_ALLOCATED_AT_STACKS
160
+ ) -> dict[str, Any] | None:
161
+ """``allocated_at`` of a retainer or instance, with at most ``max_stacks`` stacks."""
162
+ if not isinstance(at, dict):
163
+ return None
164
+ raw = [s for s in at.get("stacks") or [] if isinstance(s, dict)]
165
+ stacks = []
166
+ for s in raw[:max_stacks]:
167
+ item = {
168
+ "linked_objects": s.get("linked_objects"),
169
+ "linked_bytes": s.get("linked_bytes"),
170
+ "exact_linked_bytes": s.get("exact_linked_bytes"),
171
+ "top_stack_rank": s.get("top_stack_rank"),
172
+ **_stack_common(s.get("frames")),
173
+ }
174
+ stacks.append(with_human(item, "linked_bytes"))
175
+ out = {
176
+ "linked_objects": at.get("linked_objects"),
177
+ "linked_bytes": at.get("linked_bytes"),
178
+ "exact_linked_bytes": at.get("exact_linked_bytes"),
179
+ "approximate": at.get("approximate"),
180
+ "stacks": stacks,
181
+ "stacks_total": len(raw),
182
+ }
183
+ return with_human(out, "linked_bytes", "exact_linked_bytes")
184
+
185
+
186
+ def allocation_notes(items: list[dict[str, Any]] | None) -> list[dict[str, Any]]:
187
+ """The notes about the profile (``allocations_*`` codes)."""
188
+ return [
189
+ n
190
+ for n in notes(items)
191
+ if isinstance(n.get("code"), str) and n["code"].startswith("allocations_")
192
+ ]
193
+
194
+
195
+ def _not_linked_warning(note_list: list[dict[str, Any]]) -> str | None:
196
+ if any(n.get("code") == "allocations_not_linked" for n in note_list):
197
+ return (
198
+ "No object was linked to a sample: the profile does not match the snapshot (see "
199
+ "the allocations_* notes: another pid, another CPython version, or a snapshot "
200
+ "taken before recording started). top_stacks still shows estimated bytes, but "
201
+ "linked_bytes is 0 everywhere."
202
+ )
203
+ return None
204
+
205
+
206
+ def summarize_allocations(
207
+ block: dict[str, Any] | None, doc_notes: list[dict[str, Any]] | None, top: int
208
+ ) -> dict[str, Any] | None:
209
+ """The ``allocations`` block of an analyze report, bounded to ``top`` stacks."""
210
+ if not isinstance(block, dict):
211
+ return None
212
+ sampling = block.get("sampling") if isinstance(block.get("sampling"), dict) else {}
213
+ overhead = block.get("overhead") if isinstance(block.get("overhead"), dict) else {}
214
+ coverage = dict(block.get("coverage") or {})
215
+ with_human(coverage, "linked_bytes", "exact_linked_bytes", "unlinked_reused_bytes")
216
+ recording = {
217
+ "path": clip_left(block.get("path"), 400),
218
+ "pid": block.get("pid"),
219
+ "python_version": block.get("python_version"),
220
+ "started_at": block.get("started_at"),
221
+ "duration_ms": block.get("duration_ms"),
222
+ "mechanisms": list(block.get("mechanisms") or [])[:8],
223
+ "sampling": {
224
+ "events": list(sampling.get("events") or [])[:8],
225
+ "byte_interval": sampling.get("byte_interval"),
226
+ "max_frames": sampling.get("max_frames"),
227
+ },
228
+ "overhead": {
229
+ k: overhead.get(k)
230
+ for k in ("samples_taken", "samples_dropped", "probe_hits", "total_pause_ns")
231
+ },
232
+ "truncated": block.get("truncated"),
233
+ "approximate": block.get("approximate"),
234
+ "samples": block.get("samples"),
235
+ "estimated_bytes": block.get("estimated_bytes"),
236
+ }
237
+ with_human(recording, "estimated_bytes")
238
+ stacks = [s for s in block.get("top_stacks") or [] if isinstance(s, dict)]
239
+ note_list = allocation_notes(doc_notes)
240
+ out: dict[str, Any] = {
241
+ "recording": recording,
242
+ "coverage": coverage,
243
+ "top_stacks": [summarize_top_stack(s) for s in stacks[:top]],
244
+ "top_stacks_listed": len(stacks),
245
+ "notes": note_list,
246
+ "caveats": list(CAVEATS),
247
+ }
248
+ warning = _not_linked_warning(note_list)
249
+ if warning:
250
+ out["warning"] = warning
251
+ return out
252
+
253
+
254
+ def add_to_analyze(summary: dict[str, Any], doc: dict[str, Any], top: int) -> dict[str, Any]:
255
+ """Add the allocation join of an analyze report to its summary (in place)."""
256
+ block = doc.get("allocations")
257
+ if not isinstance(block, dict):
258
+ return summary
259
+ raw = [r for r in doc.get("top_retainers") or [] if isinstance(r, dict)]
260
+ for item, r in zip(summary.get("top_retainers") or [], raw, strict=False):
261
+ item["allocated_at"] = summarize_allocated_at(r.get("allocated_at"))
262
+ summary["allocations"] = summarize_allocations(
263
+ block, doc.get("notes"), min(top, MAX_SUMMARY_STACKS)
264
+ )
265
+ return summary
266
+
267
+
268
+ def add_to_type_query(summary: dict[str, Any], doc: dict[str, Any]) -> dict[str, Any]:
269
+ """Add ``allocated_at`` of each instance of a ``query --type --allocations`` report."""
270
+ raw = [i for i in doc.get("top_instances") or [] if isinstance(i, dict)]
271
+ for item, inst in zip(summary.get("top_instances") or [], raw, strict=False):
272
+ item["allocated_at"] = summarize_allocated_at(inst.get("allocated_at"))
273
+ note_list = allocation_notes(doc.get("notes"))
274
+ out: dict[str, Any] = {"notes": note_list, "caveats": list(CAVEATS)}
275
+ warning = _not_linked_warning(note_list)
276
+ if warning:
277
+ out["warning"] = warning
278
+ summary["allocations"] = out
279
+ return summary
280
+
281
+
282
+ # -- profile only: folded stacks -----------------------------------------------------
283
+
284
+
285
+ def _parse_folded_frame(text: str) -> dict[str, Any]:
286
+ m = _FOLDED_FRAME_RE.match(text)
287
+ if not m:
288
+ # "[no Python stack]" and anything unexpected.
289
+ return {"function": text, "file": None, "line": None, "location": None}
290
+ function, location = m.group("function"), m.group("location")
291
+ file: str | None = None
292
+ line: int | None = None
293
+ if location != "<unknown>":
294
+ head, sep, tail = location.rpartition(":")
295
+ if sep and (tail.isdigit() or tail == "?"):
296
+ file = head
297
+ line = int(tail) if tail.isdigit() else None
298
+ else:
299
+ file = location
300
+ return {"function": function, "file": file, "line": line, "location": location}
301
+
302
+
303
+ def parse_folded(text: str) -> list[tuple[list[dict[str, Any]], int]]:
304
+ """``outer;...;inner bytes`` lines as (frames innermost first, bytes)."""
305
+ out: list[tuple[list[dict[str, Any]], int]] = []
306
+ for raw in text.splitlines()[:MAX_FOLDED_LINES]:
307
+ line = raw.rstrip()
308
+ stack, sep, weight = line.rpartition(" ")
309
+ if not sep or not weight.isdigit() or not stack:
310
+ continue
311
+ frames = [_parse_folded_frame(f) for f in stack.split(";")]
312
+ frames.reverse()
313
+ out.append((frames, int(weight)))
314
+ return out
315
+
316
+
317
+ def summarize_folded(text: str, top: int) -> dict[str, Any]:
318
+ """Top stacks of a profile from its folded stacks, merged by identical frames."""
319
+ merged: dict[tuple[tuple[Any, ...], ...], int] = {}
320
+ order: dict[tuple[tuple[Any, ...], ...], list[dict[str, Any]]] = {}
321
+ for frames, weight in parse_folded(text):
322
+ key = tuple((f["function"], f["location"]) for f in frames)
323
+ merged[key] = merged.get(key, 0) + weight
324
+ order.setdefault(key, frames)
325
+ ranked = sorted(merged.items(), key=lambda kv: -kv[1])
326
+ total = sum(merged.values())
327
+ stacks = []
328
+ for rank, (key, weight) in enumerate(ranked[:top], start=1):
329
+ frames = [{**f, "approximate": None} for f in order[key]]
330
+ item: dict[str, Any] = {
331
+ "rank": rank,
332
+ "estimated_bytes": weight,
333
+ "estimated_pct": round(weight / total * 100, 2) if total else None,
334
+ # Not in folded output; available with a snapshot.
335
+ "samples": None,
336
+ "events": None,
337
+ "linked_bytes": None,
338
+ "exact_linked_bytes": None,
339
+ **_stack_common(frames),
340
+ }
341
+ stacks.append(with_human(item, "estimated_bytes"))
342
+ out = {
343
+ "estimated_bytes": total,
344
+ "stacks_total": len(merged),
345
+ "top_stacks": stacks,
346
+ }
347
+ return with_human(out, "estimated_bytes")