pyspy-mcp 0.1.0__tar.gz

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,37 @@
1
+ # Rust
2
+ target/
3
+ Cargo.lock
4
+
5
+ # Python
6
+ __pycache__/
7
+ *.py[cod]
8
+ *.egg-info/
9
+ *.egg
10
+ .eggs/
11
+ dist/
12
+ build/
13
+ *.whl
14
+ *.tar.gz
15
+ .pytest_cache/
16
+ .mypy_cache/
17
+ .ruff_cache/
18
+
19
+ # Virtual environments
20
+ venv/
21
+ .env/
22
+ .venv/
23
+
24
+ # IDE
25
+ .vscode/
26
+ .idea/
27
+ *.swp
28
+ *.swo
29
+
30
+ # OS
31
+ .DS_Store
32
+ Thumbs.db
33
+
34
+ # Local overrides
35
+ .env
36
+ .pyspy-mcp-server.json
37
+ .zcode/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 pyspy-mcp-server contributors
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.
@@ -0,0 +1,151 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyspy-mcp
3
+ Version: 0.1.0
4
+ Summary: MCP server for Python performance testing with py-spy
5
+ Project-URL: Homepage, https://github.com/LBurny/pyspy-mcp
6
+ Project-URL: Repository, https://github.com/LBurny/pyspy-mcp
7
+ Project-URL: Issues, https://github.com/LBurny/pyspy-mcp/issues
8
+ Author: LBunny
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: mcp,performance,profiler,py-spy,python
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
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
+ Classifier: Topic :: Software Development :: Debuggers
21
+ Classifier: Topic :: System :: Monitoring
22
+ Requires-Python: >=3.10
23
+ Requires-Dist: fastmcp>=3.0
24
+ Requires-Dist: psutil>=5.9
25
+ Requires-Dist: py-spy>=0.4.2
26
+ Requires-Dist: pydantic>=2.0
27
+ Provides-Extra: dev
28
+ Requires-Dist: build>=1.0; extra == 'dev'
29
+ Requires-Dist: pytest>=7.0; extra == 'dev'
30
+ Requires-Dist: twine>=5.0; extra == 'dev'
31
+ Description-Content-Type: text/markdown
32
+
33
+ # py-spy MCP Server
34
+
35
+ A [Model Context Protocol (MCP)](https://modelcontextprotocol.io) server that exposes Python performance testing tools powered by [py-spy](https://github.com/benfred/py-spy).
36
+
37
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
38
+ ![Python 3.10+](https://img.shields.io/badge/Python-3.10+-3776AB?logo=python&logoColor=white)
39
+ ![MCP Compatible](https://img.shields.io/badge/MCP-Compatible-2ea44f)
40
+ ![tests passing](https://img.shields.io/badge/tests-passing-brightgreen)
41
+
42
+ 🌐 English | [įŽ€äŊ“中文](README.zh.md)
43
+
44
+ **Profile Python in context.** Sample live processes, generate flamegraphs, dump stacks, and compare runs — all through MCP.
45
+
46
+ ## Features
47
+
48
+ - **Profile by PID or command** — sample a running Python process or launch a new one directly.
49
+ - **`record_profile`** — generate profiles in multiple formats:
50
+ - `speedscope` (interactive JSON)
51
+ - `flamegraph` (self-contained SVG)
52
+ - `raw` (stack-count text)
53
+ - `chrometrace` (Chrome DevTools timeline JSON)
54
+ - **`dump_stacks`** — capture the current Python call stacks of a process as JSON or human-readable text.
55
+ - **`list_python_processes`** — list running Python processes on the machine to pick a target.
56
+ - **`analyze_profile`** — parse an existing profile and return the hottest frames.
57
+ - **`compare_profiles`** — compare two speedscope profiles and show percentage changes.
58
+ - **`top_profile`** — run a short `py-spy top` session and return a summary.
59
+ - On Windows, `py-spy top` cannot be captured through a pipe, so this tool falls back to a short raw recording and returns the hottest frames.
60
+ - **Low-overhead sampling** — powered by [py-spy](https://github.com/benfred/py-spy); reads process memory without modifying or running inside the target process.
61
+ - **Cross-platform** — works on Linux, macOS, and Windows (subject to OS permissions).
62
+ - **Local-source friendly** — during development the server automatically prefers a `py-spy` binary built from the sibling Rust source (`src/pyspy/`).
63
+ - **Optional native/C extension profiling** — enable `--native` where the platform supports it.
64
+ - **GIL and idle filtering** — focus on active threads or GIL-holding threads.
65
+
66
+ ## Installation (from PyPI)
67
+
68
+ Using `pip`:
69
+
70
+ ```bash
71
+ pip install pyspy-mcp
72
+ ```
73
+
74
+ Using `uv`:
75
+
76
+ ```bash
77
+ uv pip install pyspy-mcp
78
+ # or install as a global tool
79
+ uv tool install pyspy-mcp
80
+ ```
81
+
82
+ This will automatically install the compatible `py-spy` binary wheel for your platform.
83
+
84
+ ## Running with Claude Desktop / Claude Code
85
+
86
+ Add the server as a **Local command** connector:
87
+
88
+ ```json
89
+ {
90
+ "mcpServers": {
91
+ "pyspy": {
92
+ "command": "pyspy-mcp"
93
+ }
94
+ }
95
+ }
96
+ ```
97
+
98
+ Or run directly:
99
+
100
+ ```bash
101
+ pyspy-mcp
102
+ ```
103
+
104
+ The server speaks MCP over stdio.
105
+
106
+ ## Development (from source)
107
+
108
+ If you want to use the local py-spy Rust source instead of the PyPI package:
109
+
110
+ ```bash
111
+ # Build py-spy from the local Rust source
112
+ cargo build --release
113
+
114
+ # The binary will be at:
115
+ # target/release/py-spy (Linux / macOS)
116
+ # target/release/py-spy.exe (Windows)
117
+
118
+ # Install the Python MCP package in editable mode
119
+ pip install -e ".[dev]"
120
+
121
+ # Or using uv
122
+ uv pip install -e ".[dev]"
123
+
124
+ # Run tests
125
+ python -m pytest tests/pyspy_mcp -v
126
+ ```
127
+
128
+ The server will automatically prefer a locally built binary at `target/release/py-spy[.exe]` over the `py-spy` installed from PyPI. You can also force a specific binary by setting the environment variable:
129
+
130
+ ```bash
131
+ export PYSPY_MCP_BINARY=/path/to/py-spy
132
+ ```
133
+
134
+ ## Publishing to PyPI
135
+
136
+ ```bash
137
+ python -m build
138
+ python -m twine upload dist/*
139
+ ```
140
+
141
+ The published wheel is a pure-Python `py3-none-any` package and depends on the upstream `py-spy` PyPI package. If you modify the Rust source and want to ship those changes, you will need to build platform-specific wheels (or bundle the rebuilt `py-spy` binary as package data).
142
+
143
+ ## Permissions
144
+
145
+ - On Linux, profiling an existing PID usually requires `ptrace` permissions (`sudo` or `cap_sys_ptrace`).
146
+ - On macOS, profiling often requires root due to System Integrity Protection (SIP).
147
+ - On Windows, running as Administrator may be needed for some processes.
148
+
149
+ ## Configuration
150
+
151
+ Set `PYSPY_MCP_BINARY` to override the bundled/development py-spy binary location.
@@ -0,0 +1,119 @@
1
+ # py-spy MCP Server
2
+
3
+ A [Model Context Protocol (MCP)](https://modelcontextprotocol.io) server that exposes Python performance testing tools powered by [py-spy](https://github.com/benfred/py-spy).
4
+
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
6
+ ![Python 3.10+](https://img.shields.io/badge/Python-3.10+-3776AB?logo=python&logoColor=white)
7
+ ![MCP Compatible](https://img.shields.io/badge/MCP-Compatible-2ea44f)
8
+ ![tests passing](https://img.shields.io/badge/tests-passing-brightgreen)
9
+
10
+ 🌐 English | [įŽ€äŊ“中文](README.zh.md)
11
+
12
+ **Profile Python in context.** Sample live processes, generate flamegraphs, dump stacks, and compare runs — all through MCP.
13
+
14
+ ## Features
15
+
16
+ - **Profile by PID or command** — sample a running Python process or launch a new one directly.
17
+ - **`record_profile`** — generate profiles in multiple formats:
18
+ - `speedscope` (interactive JSON)
19
+ - `flamegraph` (self-contained SVG)
20
+ - `raw` (stack-count text)
21
+ - `chrometrace` (Chrome DevTools timeline JSON)
22
+ - **`dump_stacks`** — capture the current Python call stacks of a process as JSON or human-readable text.
23
+ - **`list_python_processes`** — list running Python processes on the machine to pick a target.
24
+ - **`analyze_profile`** — parse an existing profile and return the hottest frames.
25
+ - **`compare_profiles`** — compare two speedscope profiles and show percentage changes.
26
+ - **`top_profile`** — run a short `py-spy top` session and return a summary.
27
+ - On Windows, `py-spy top` cannot be captured through a pipe, so this tool falls back to a short raw recording and returns the hottest frames.
28
+ - **Low-overhead sampling** — powered by [py-spy](https://github.com/benfred/py-spy); reads process memory without modifying or running inside the target process.
29
+ - **Cross-platform** — works on Linux, macOS, and Windows (subject to OS permissions).
30
+ - **Local-source friendly** — during development the server automatically prefers a `py-spy` binary built from the sibling Rust source (`src/pyspy/`).
31
+ - **Optional native/C extension profiling** — enable `--native` where the platform supports it.
32
+ - **GIL and idle filtering** — focus on active threads or GIL-holding threads.
33
+
34
+ ## Installation (from PyPI)
35
+
36
+ Using `pip`:
37
+
38
+ ```bash
39
+ pip install pyspy-mcp
40
+ ```
41
+
42
+ Using `uv`:
43
+
44
+ ```bash
45
+ uv pip install pyspy-mcp
46
+ # or install as a global tool
47
+ uv tool install pyspy-mcp
48
+ ```
49
+
50
+ This will automatically install the compatible `py-spy` binary wheel for your platform.
51
+
52
+ ## Running with Claude Desktop / Claude Code
53
+
54
+ Add the server as a **Local command** connector:
55
+
56
+ ```json
57
+ {
58
+ "mcpServers": {
59
+ "pyspy": {
60
+ "command": "pyspy-mcp"
61
+ }
62
+ }
63
+ }
64
+ ```
65
+
66
+ Or run directly:
67
+
68
+ ```bash
69
+ pyspy-mcp
70
+ ```
71
+
72
+ The server speaks MCP over stdio.
73
+
74
+ ## Development (from source)
75
+
76
+ If you want to use the local py-spy Rust source instead of the PyPI package:
77
+
78
+ ```bash
79
+ # Build py-spy from the local Rust source
80
+ cargo build --release
81
+
82
+ # The binary will be at:
83
+ # target/release/py-spy (Linux / macOS)
84
+ # target/release/py-spy.exe (Windows)
85
+
86
+ # Install the Python MCP package in editable mode
87
+ pip install -e ".[dev]"
88
+
89
+ # Or using uv
90
+ uv pip install -e ".[dev]"
91
+
92
+ # Run tests
93
+ python -m pytest tests/pyspy_mcp -v
94
+ ```
95
+
96
+ The server will automatically prefer a locally built binary at `target/release/py-spy[.exe]` over the `py-spy` installed from PyPI. You can also force a specific binary by setting the environment variable:
97
+
98
+ ```bash
99
+ export PYSPY_MCP_BINARY=/path/to/py-spy
100
+ ```
101
+
102
+ ## Publishing to PyPI
103
+
104
+ ```bash
105
+ python -m build
106
+ python -m twine upload dist/*
107
+ ```
108
+
109
+ The published wheel is a pure-Python `py3-none-any` package and depends on the upstream `py-spy` PyPI package. If you modify the Rust source and want to ship those changes, you will need to build platform-specific wheels (or bundle the rebuilt `py-spy` binary as package data).
110
+
111
+ ## Permissions
112
+
113
+ - On Linux, profiling an existing PID usually requires `ptrace` permissions (`sudo` or `cap_sys_ptrace`).
114
+ - On macOS, profiling often requires root due to System Integrity Protection (SIP).
115
+ - On Windows, running as Administrator may be needed for some processes.
116
+
117
+ ## Configuration
118
+
119
+ Set `PYSPY_MCP_BINARY` to override the bundled/development py-spy binary location.
@@ -0,0 +1,64 @@
1
+ [project]
2
+ name = "pyspy-mcp"
3
+ version = "0.1.0"
4
+ description = "MCP server for Python performance testing with py-spy"
5
+ readme = "README.md"
6
+ license = {text = "MIT"}
7
+ requires-python = ">=3.10"
8
+ authors = [
9
+ {name = "LBunny"},
10
+ ]
11
+ keywords = ["mcp", "py-spy", "profiler", "python", "performance"]
12
+ classifiers = [
13
+ "Development Status :: 3 - Alpha",
14
+ "Intended Audience :: Developers",
15
+ "License :: OSI Approved :: MIT License",
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3.10",
18
+ "Programming Language :: Python :: 3.11",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Programming Language :: Python :: 3.13",
21
+ "Topic :: Software Development :: Debuggers",
22
+ "Topic :: System :: Monitoring",
23
+ ]
24
+ dependencies = [
25
+ "fastmcp>=3.0",
26
+ "psutil>=5.9",
27
+ "pydantic>=2.0",
28
+ # PyPI-distributed py-spy command-line tool. The local source build is used
29
+ # automatically during development (see py_spy_finder.py).
30
+ "py-spy>=0.4.2",
31
+ ]
32
+
33
+ [project.urls]
34
+ Homepage = "https://github.com/LBurny/pyspy-mcp"
35
+ Repository = "https://github.com/LBurny/pyspy-mcp"
36
+ Issues = "https://github.com/LBurny/pyspy-mcp/issues"
37
+
38
+ [project.optional-dependencies]
39
+ dev = [
40
+ "pytest>=7.0",
41
+ "build>=1.0",
42
+ "twine>=5.0",
43
+ ]
44
+
45
+ [project.scripts]
46
+ pyspy-mcp = "pyspy_mcp.server:main"
47
+
48
+ [build-system]
49
+ requires = ["hatchling"]
50
+ build-backend = "hatchling.build"
51
+
52
+ [tool.hatch.build.targets.wheel]
53
+ packages = ["src/pyspy_mcp"]
54
+ # Include the bundled py-spy binary when present.
55
+ artifacts = ["src/pyspy_mcp/bin/*"]
56
+
57
+ [tool.hatch.build.targets.sdist]
58
+ only-packages = true
59
+
60
+ [tool.pytest.ini_options]
61
+ testpaths = ["tests/pyspy_mcp"]
62
+ filterwarnings = [
63
+ "ignore::DeprecationWarning",
64
+ ]
@@ -0,0 +1,3 @@
1
+ """MCP server for Python performance testing with py-spy."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,150 @@
1
+ """Parsers for py-spy output formats."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from collections import Counter
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import Dict, List
11
+
12
+
13
+ @dataclass
14
+ class HotFrame:
15
+ name: str
16
+ file: str | None
17
+ line: int | None
18
+ samples: int
19
+ percent: float
20
+
21
+
22
+ def parse_speedscope(profile_path: str | Path) -> Dict:
23
+ """Load a speedscope JSON file and return the raw dict."""
24
+ with open(profile_path, "r", encoding="utf-8") as f:
25
+ return json.load(f)
26
+
27
+
28
+ def analyze_speedscope(profile_path: str | Path, top_n: int = 10) -> List[HotFrame]:
29
+ """Aggregate a speedscope profile into the most frequently sampled frames."""
30
+ data = parse_speedscope(profile_path)
31
+ frames = data.get("shared", {}).get("frames", [])
32
+ counts: Counter[int] = Counter()
33
+ total = 0
34
+ for profile in data.get("profiles", []):
35
+ for sample in profile.get("samples", []):
36
+ # Deduplicate frames within a single sample to avoid overweighting deep stacks.
37
+ seen = set()
38
+ for frame_idx in sample:
39
+ if frame_idx not in seen:
40
+ counts[frame_idx] += 1
41
+ seen.add(frame_idx)
42
+ total += len(sample)
43
+
44
+ if total == 0:
45
+ return []
46
+
47
+ hot = []
48
+ for idx, sample_count in counts.most_common(top_n):
49
+ if idx < 0 or idx >= len(frames):
50
+ continue
51
+ frame = frames[idx]
52
+ hot.append(
53
+ HotFrame(
54
+ name=frame.get("name", "<unknown>"),
55
+ file=frame.get("file"),
56
+ line=frame.get("line"),
57
+ samples=sample_count,
58
+ percent=round(100.0 * sample_count / total, 2),
59
+ )
60
+ )
61
+ return hot
62
+
63
+
64
+ def parse_raw(profile_path: str | Path, top_n: int = 10) -> List[HotFrame]:
65
+ """Parse py-spy raw output (semicolon-delimited stacks with counts)."""
66
+ counts: Counter[str] = Counter()
67
+ total = 0
68
+ with open(profile_path, "r", encoding="utf-8") as f:
69
+ for line in f:
70
+ line = line.strip()
71
+ if not line:
72
+ continue
73
+ if ";" not in line:
74
+ continue
75
+ # py-spy raw format: frame1 (file:line);frame2 (file:line) count
76
+ try:
77
+ stack_str, count_str = line.rsplit(" ", 1)
78
+ count = int(count_str)
79
+ except ValueError:
80
+ continue
81
+ counts[stack_str] += count
82
+ total += count
83
+
84
+ if total == 0:
85
+ return []
86
+
87
+ hot = []
88
+ for stack, sample_count in counts.most_common(top_n):
89
+ # Use the innermost (last) frame as the representative name.
90
+ top_frame = stack.split(";")[-1]
91
+ hot.append(
92
+ HotFrame(
93
+ name=top_frame,
94
+ file=None,
95
+ line=None,
96
+ samples=sample_count,
97
+ percent=round(100.0 * sample_count / total, 2),
98
+ )
99
+ )
100
+ return hot
101
+
102
+
103
+ def parse_dump_json(dump_path: str | Path) -> List[Dict]:
104
+ """Load the output of ``py-spy dump --json``."""
105
+ with open(dump_path, "r", encoding="utf-8") as f:
106
+ return json.load(f)
107
+
108
+
109
+ def format_hot_frames(frames: List[HotFrame]) -> str:
110
+ """Render a list of hot frames as a Markdown table."""
111
+ lines = ["| Function | File | Line | Samples | % |", "|---|---|---|---|---|"]
112
+ for f in frames:
113
+ file_cell = f.file or "-"
114
+ line_cell = str(f.line) if f.line is not None else "-"
115
+ lines.append(f"| {f.name} | {file_cell} | {line_cell} | {f.samples} | {f.percent} |")
116
+ return "\n".join(lines)
117
+
118
+
119
+ def _frame_signature(frame: Dict) -> str:
120
+ """Canonical string for a frame used when comparing profiles."""
121
+ return f"{frame.get('name')}@{frame.get('filename')}:{frame.get('line', 0)}"
122
+
123
+
124
+ def compare_profiles(
125
+ profile_a: str | Path,
126
+ profile_b: str | Path,
127
+ top_n: int = 10,
128
+ ) -> str:
129
+ """Compare two speedscope profiles and highlight changes."""
130
+ frames_a = {f.name: f for f in analyze_speedscope(profile_a, top_n=top_n * 2)}
131
+ frames_b = {f.name: f for f in analyze_speedscope(profile_b, top_n=top_n * 2)}
132
+ all_names = set(frames_a.keys()) | set(frames_b.keys())
133
+
134
+ rows = []
135
+ names = sorted(
136
+ all_names,
137
+ key=lambda n: -(frames_b.get(n, HotFrame(n, None, None, 0, 0.0)).percent),
138
+ )
139
+ for name in names[:top_n]:
140
+ a = frames_a.get(name)
141
+ b = frames_b.get(name)
142
+ pct_a = a.percent if a else 0.0
143
+ pct_b = b.percent if b else 0.0
144
+ delta = round(pct_b - pct_a, 2)
145
+ rows.append((name, pct_a, pct_b, delta))
146
+
147
+ lines = ["| Function | % in A | % in B | Δ |", "|---|---|---|---|"]
148
+ for name, pct_a, pct_b, delta in rows:
149
+ lines.append(f"| {name} | {pct_a} | {pct_b} | {delta:+} |")
150
+ return "\n".join(lines)
@@ -0,0 +1,64 @@
1
+ """Utilities for finding Python processes on the local machine."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import List
7
+
8
+ import psutil
9
+
10
+
11
+ @dataclass
12
+ class PythonProcess:
13
+ pid: int
14
+ cmdline: str
15
+ create_time: float
16
+ username: str | None
17
+ memory_mb: float
18
+
19
+
20
+ def list_python_processes() -> List[PythonProcess]:
21
+ """Return a list of running processes whose executable looks like Python."""
22
+ processes: List[PythonProcess] = []
23
+ for proc in psutil.process_iter(["pid", "name", "cmdline", "create_time", "username", "memory_info"]):
24
+ try:
25
+ name = proc.info["name"] or ""
26
+ cmdline = proc.info["cmdline"] or []
27
+ cmdline_str = " ".join(cmdline) if cmdline else name
28
+ if not _is_python_process(name, cmdline):
29
+ continue
30
+ mem = proc.info.get("memory_info")
31
+ processes.append(
32
+ PythonProcess(
33
+ pid=proc.info["pid"],
34
+ cmdline=cmdline_str,
35
+ create_time=proc.info["create_time"] or 0.0,
36
+ username=proc.info.get("username"),
37
+ memory_mb=(mem.rss / (1024 * 1024)) if mem else 0.0,
38
+ )
39
+ )
40
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
41
+ continue
42
+ return processes
43
+
44
+
45
+ def _is_python_process(name: str, cmdline: List[str]) -> bool:
46
+ """Heuristic to decide whether a process is a Python interpreter."""
47
+ lowered = name.lower()
48
+ if "python" in lowered:
49
+ return True
50
+ if not cmdline:
51
+ return False
52
+ first = cmdline[0].lower()
53
+ # Handle cases like /usr/bin/python3.11 or python.exe
54
+ if "python" in first:
55
+ return True
56
+ return False
57
+
58
+
59
+ def format_process_list(processes: List[PythonProcess], max_count: int = 50) -> str:
60
+ """Format a list of Python processes as a human-readable table."""
61
+ lines = ["PID\tMemory(MB)\tCommand"]
62
+ for p in processes[:max_count]:
63
+ lines.append(f"{p.pid}\t{p.memory_mb:.1f}\t\t{p.cmdline[:120]}")
64
+ return "\n".join(lines)
@@ -0,0 +1,75 @@
1
+ """Locate the py-spy binary used by this MCP server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import platform
7
+ import shutil
8
+ import sys
9
+ from pathlib import Path
10
+
11
+
12
+ def _binary_name() -> str:
13
+ return "py-spy.exe" if platform.system() == "Windows" else "py-spy"
14
+
15
+
16
+ def _development_binary() -> Path | None:
17
+ """Look for a binary built from the sibling Rust workspace."""
18
+ # __file__ = I:/pyspy_mcp/src/pyspy_mcp/py_spy_finder.py
19
+ workspace_root = Path(__file__).resolve().parent.parent.parent
20
+ candidate = workspace_root / "target" / "release" / _binary_name()
21
+ if candidate.exists():
22
+ return candidate
23
+ return None
24
+
25
+
26
+ def _bundled_binary() -> Path | None:
27
+ """Look for a binary shipped inside this Python package."""
28
+ package_dir = Path(__file__).resolve().parent
29
+ candidate = package_dir / "bin" / _binary_name()
30
+ if candidate.exists():
31
+ return candidate
32
+ return None
33
+
34
+
35
+ def find_py_spy() -> str:
36
+ """Return the path to the py-spy binary.
37
+
38
+ Resolution order:
39
+ 1. ``PYSPY_MCP_BINARY`` environment variable.
40
+ 2. A binary built in the sibling Rust workspace (``target/release``).
41
+ 3. A binary bundled inside this package.
42
+ 4. ``py-spy`` on ``PATH``.
43
+
44
+ Raises:
45
+ FileNotFoundError: if no py-spy binary can be found.
46
+ """
47
+ env_binary = os.environ.get("PYSPY_MCP_BINARY")
48
+ if env_binary:
49
+ path = Path(env_binary)
50
+ if path.exists():
51
+ return str(path)
52
+ raise FileNotFoundError(
53
+ f"PYSPY_MCP_BINARY points to a missing file: {env_binary}"
54
+ )
55
+
56
+ dev = _development_binary()
57
+ if dev:
58
+ return str(dev)
59
+
60
+ bundled = _bundled_binary()
61
+ if bundled:
62
+ return str(bundled)
63
+
64
+ path_binary = shutil.which("py-spy")
65
+ if path_binary:
66
+ return path_binary
67
+
68
+ raise FileNotFoundError(
69
+ "Could not find a py-spy binary. Set PYSPY_MCP_BINARY, "
70
+ "install py-spy on PATH, or build the Rust workspace first."
71
+ )
72
+
73
+
74
+ if __name__ == "__main__":
75
+ print(find_py_spy())
@@ -0,0 +1,168 @@
1
+ """FastMCP server exposing py-spy based Python performance tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from typing import List, Optional
7
+
8
+ from fastmcp import FastMCP
9
+
10
+ from . import tools
11
+
12
+ mcp = FastMCP("pyspy-mcp")
13
+
14
+
15
+ @mcp.tool()
16
+ def record_profile(
17
+ pid: Optional[int] = None,
18
+ command: Optional[List[str]] = None,
19
+ duration: int = 5,
20
+ output_format: str = "speedscope",
21
+ rate: int = 100,
22
+ native: bool = False,
23
+ idle: bool = False,
24
+ gil: bool = False,
25
+ subprocesses: bool = False,
26
+ ) -> str:
27
+ """Record a sampling profile of a Python process or command.
28
+
29
+ Args:
30
+ pid: Process ID to sample. Use this OR command, not both.
31
+ command: Command to run and sample, e.g. ["python", "script.py"]. Use this OR pid.
32
+ duration: How many seconds to sample (default 5).
33
+ output_format: One of "speedscope" (JSON), "flamegraph" (SVG), "raw", "chrometrace".
34
+ rate: Samples per second (default 100).
35
+ native: Include native/C extension frames if supported on this platform.
36
+ idle: Include idle threads.
37
+ gil: Only include traces holding the GIL.
38
+ subprocesses: Include child Python processes.
39
+
40
+ Returns:
41
+ The generated profile content (JSON, SVG, or raw text).
42
+ """
43
+ return tools.record_profile(
44
+ pid=pid,
45
+ command=command,
46
+ duration=duration,
47
+ output_format=output_format,
48
+ rate=rate,
49
+ native=native,
50
+ idle=idle,
51
+ gil=gil,
52
+ subprocesses=subprocesses,
53
+ )
54
+
55
+
56
+ @mcp.tool()
57
+ def dump_stacks(
58
+ pid: int,
59
+ json_output: bool = True,
60
+ locals_level: int = 0,
61
+ subprocesses: bool = False,
62
+ native: bool = False,
63
+ ) -> str:
64
+ """Dump the current Python call stacks of a process.
65
+
66
+ Args:
67
+ pid: Process ID to inspect.
68
+ json_output: Return structured JSON instead of human-readable text.
69
+ locals_level: Number of times to pass --locals (0-2). Each level shows more locals.
70
+ subprocesses: Include child Python processes.
71
+ native: Include native frames if supported.
72
+
73
+ Returns:
74
+ The stack dump as JSON or text.
75
+ """
76
+ return tools.dump_stacks(
77
+ pid=pid,
78
+ json_output=json_output,
79
+ locals_level=locals_level,
80
+ subprocesses=subprocesses,
81
+ native=native,
82
+ )
83
+
84
+
85
+ @mcp.tool()
86
+ def list_python_processes() -> str:
87
+ """List running Python processes on this machine.
88
+
89
+ Returns:
90
+ A table of PID, memory usage, and command line.
91
+ """
92
+ return tools.list_python_processes_tool()
93
+
94
+
95
+ @mcp.tool()
96
+ def analyze_profile(profile_path: str, top_n: int = 10) -> str:
97
+ """Analyze an existing py-spy profile and return the hottest frames.
98
+
99
+ Args:
100
+ profile_path: Path to a speedscope JSON (.json) or raw (.txt) profile.
101
+ top_n: Number of top frames to return (default 10).
102
+
103
+ Returns:
104
+ A Markdown table of hot frames with sample counts and percentages.
105
+ """
106
+ return tools.analyze_profile(profile_path, top_n=top_n)
107
+
108
+
109
+ @mcp.tool()
110
+ def compare_profiles(profile_a: str, profile_b: str, top_n: int = 10) -> str:
111
+ """Compare two speedscope profiles and show percentage changes.
112
+
113
+ Args:
114
+ profile_a: Path to the first profile.
115
+ profile_b: Path to the second profile.
116
+ top_n: Number of frames to show (default 10).
117
+
118
+ Returns:
119
+ A Markdown table showing the percentage of time in each profile and the delta.
120
+ """
121
+ return tools.compare_profiles(profile_a, profile_b, top_n=top_n)
122
+
123
+
124
+ @mcp.tool()
125
+ def top_profile(
126
+ pid: Optional[int] = None,
127
+ command: Optional[List[str]] = None,
128
+ duration: int = 5,
129
+ rate: int = 100,
130
+ native: bool = False,
131
+ idle: bool = False,
132
+ gil: bool = False,
133
+ subprocesses: bool = False,
134
+ ) -> str:
135
+ """Run a live ``py-spy top`` view for a few seconds and return the summary.
136
+
137
+ Args:
138
+ pid: Process ID. Use this OR command.
139
+ command: Command to run and monitor. Use this OR pid.
140
+ duration: How many seconds to run top (default 5).
141
+ rate: Samples per second (default 100).
142
+ native: Include native frames if supported.
143
+ idle: Include idle threads.
144
+ gil: Only show traces holding the GIL.
145
+ subprocesses: Include child processes.
146
+
147
+ Returns:
148
+ The final top summary lines.
149
+ """
150
+ return tools.top_profile(
151
+ pid=pid,
152
+ command=command,
153
+ duration=duration,
154
+ rate=rate,
155
+ native=native,
156
+ idle=idle,
157
+ gil=gil,
158
+ subprocesses=subprocesses,
159
+ )
160
+
161
+
162
+ def main() -> None:
163
+ """Entry point used by the ``pyspy-mcp`` console script."""
164
+ mcp.run()
165
+
166
+
167
+ if __name__ == "__main__":
168
+ main()
@@ -0,0 +1,332 @@
1
+ """Core tool implementations that drive py-spy."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import subprocess
7
+ import sys
8
+ import tempfile
9
+ from pathlib import Path
10
+ from typing import List, Optional
11
+
12
+ from . import parser
13
+ from .process_util import PythonProcess, format_process_list, list_python_processes
14
+ from .py_spy_finder import find_py_spy
15
+
16
+
17
+ # Valid output formats supported by py-spy record.
18
+ VALID_FORMATS = {"flamegraph", "speedscope", "raw", "chrometrace"}
19
+
20
+
21
+ def _run_py_spy(args: List[str], timeout: Optional[int] = None) -> subprocess.CompletedProcess:
22
+ """Run py-spy with the given arguments and return the completed process."""
23
+ binary = find_py_spy()
24
+ env = os.environ.copy()
25
+ env.setdefault("RUST_LOG", "warn")
26
+ env.setdefault("RUST_BACKTRACE", "1")
27
+ return subprocess.run(
28
+ [binary, *args],
29
+ capture_output=True,
30
+ text=True,
31
+ encoding="utf-8",
32
+ errors="replace",
33
+ env=env,
34
+ timeout=timeout,
35
+ check=False,
36
+ )
37
+
38
+
39
+ # Alias kept for backwards compatibility.
40
+ find_py_sky = find_py_spy
41
+
42
+
43
+ def record_profile(
44
+ pid: Optional[int] = None,
45
+ command: Optional[List[str]] = None,
46
+ duration: int = 5,
47
+ output_format: str = "speedscope",
48
+ rate: int = 100,
49
+ native: bool = False,
50
+ idle: bool = False,
51
+ gil: bool = False,
52
+ subprocesses: bool = False,
53
+ output_path: Optional[str] = None,
54
+ ) -> str:
55
+ """Record a profile from a Python process or command.
56
+
57
+ Returns the contents of the generated profile file as a string.
58
+ """
59
+ if output_format not in VALID_FORMATS:
60
+ raise ValueError(f"Unsupported format: {output_format}. Use one of {VALID_FORMATS}")
61
+ if not (pid or command):
62
+ raise ValueError("Either pid or command must be provided")
63
+ if pid and command:
64
+ raise ValueError("Provide either pid or command, not both")
65
+ if duration <= 0:
66
+ raise ValueError("duration must be positive")
67
+
68
+ if output_path:
69
+ out = Path(output_path)
70
+ else:
71
+ suffix = ".svg" if output_format == "flamegraph" else ".json" if output_format in ("speedscope", "chrometrace") else ".txt"
72
+ out = Path(tempfile.mkstemp(suffix=suffix, prefix="pyspy_")[1])
73
+
74
+ args = [
75
+ "record",
76
+ "-o", str(out),
77
+ "--format", output_format,
78
+ "-d", str(duration),
79
+ "-r", str(rate),
80
+ ]
81
+ if native:
82
+ args.append("--native")
83
+ if idle:
84
+ args.append("--idle")
85
+ if gil:
86
+ args.append("--gil")
87
+ if subprocesses:
88
+ args.append("--subprocesses")
89
+
90
+ if pid:
91
+ args.extend(["--pid", str(pid)])
92
+ else:
93
+ args.append("--")
94
+ args.extend(command)
95
+
96
+ result = _run_py_spy(args, timeout=duration + 30)
97
+ if result.returncode != 0:
98
+ raise RuntimeError(
99
+ f"py-spy record failed (exit {result.returncode}):\n{result.stderr}"
100
+ )
101
+
102
+ with open(out, "r", encoding="utf-8", errors="replace") as f:
103
+ return f.read()
104
+
105
+
106
+ def dump_stacks(
107
+ pid: int,
108
+ json_output: bool = True,
109
+ locals_level: int = 0,
110
+ subprocesses: bool = False,
111
+ native: bool = False,
112
+ ) -> str:
113
+ """Dump the current Python call stacks for a process."""
114
+ args = ["dump", "--pid", str(pid)]
115
+ if json_output:
116
+ args.append("--json")
117
+ for _ in range(locals_level):
118
+ args.append("--locals")
119
+ if subprocesses:
120
+ args.append("--subprocesses")
121
+ if native:
122
+ args.append("--native")
123
+
124
+ result = _run_py_spy(args, timeout=30)
125
+ if result.returncode != 0:
126
+ raise RuntimeError(
127
+ f"py-spy dump failed (exit {result.returncode}):\n{result.stderr}"
128
+ )
129
+ return result.stdout
130
+
131
+
132
+ def list_python_processes_tool() -> str:
133
+ """List running Python processes on this machine."""
134
+ processes: List[PythonProcess] = list_python_processes()
135
+ if not processes:
136
+ return "No Python processes found."
137
+ return format_process_list(processes)
138
+
139
+
140
+ def analyze_profile(profile_path: str, top_n: int = 10) -> str:
141
+ """Analyze an existing profile file and return the hottest frames."""
142
+ path = Path(profile_path)
143
+ if not path.exists():
144
+ raise FileNotFoundError(f"Profile not found: {profile_path}")
145
+
146
+ suffix = path.suffix.lower()
147
+ if suffix == ".json":
148
+ frames = parser.analyze_speedscope(path, top_n=top_n)
149
+ elif suffix == ".txt" or suffix == ".raw":
150
+ frames = parser.parse_raw(path, top_n=top_n)
151
+ else:
152
+ # Best-effort: try speedscope first, then raw.
153
+ try:
154
+ frames = parser.analyze_speedscope(path, top_n=top_n)
155
+ except Exception:
156
+ frames = parser.parse_raw(path, top_n=top_n)
157
+
158
+ if not frames:
159
+ return "No samples found in profile."
160
+ return parser.format_hot_frames(frames)
161
+
162
+
163
+ def compare_profiles(profile_a: str, profile_b: str, top_n: int = 10) -> str:
164
+ """Compare two profiles and return a table of changes."""
165
+ return parser.compare_profiles(profile_a, profile_b, top_n=top_n)
166
+
167
+
168
+ def top_profile(
169
+ pid: Optional[int] = None,
170
+ command: Optional[List[str]] = None,
171
+ duration: int = 5,
172
+ rate: int = 100,
173
+ native: bool = False,
174
+ idle: bool = False,
175
+ gil: bool = False,
176
+ subprocesses: bool = False,
177
+ tail_lines: int = 40,
178
+ ) -> str:
179
+ """Run ``py-spy top`` for a short time and capture the final output.
180
+
181
+ On Windows, ``py-spy top`` cannot be captured through a pipe, so we fall
182
+ back to a short ``record --format raw`` sample and return the hottest frames.
183
+
184
+ Args:
185
+ pid: Process ID. Use this OR command.
186
+ command: Command to run and monitor. Use this OR pid.
187
+ duration: How many seconds to run top (default 5).
188
+ rate: Samples per second (default 100).
189
+ native: Include native frames if supported.
190
+ idle: Include idle threads.
191
+ gil: Only show traces holding the GIL.
192
+ subprocesses: Include child processes.
193
+ tail_lines: Number of trailing lines to return (default 40).
194
+
195
+ Returns:
196
+ The final top summary lines, or a hot-frame table on Windows.
197
+ """
198
+ if not (pid or command):
199
+ raise ValueError("Either pid or command must be provided")
200
+ if pid and command:
201
+ raise ValueError("Provide either pid or command, not both")
202
+
203
+ if sys.platform == "win32":
204
+ return _top_profile_windows(
205
+ pid=pid,
206
+ command=command,
207
+ duration=duration,
208
+ rate=rate,
209
+ native=native,
210
+ idle=idle,
211
+ gil=gil,
212
+ subprocesses=subprocesses,
213
+ top_n=tail_lines,
214
+ )
215
+
216
+ args = [
217
+ "top",
218
+ "-r", str(rate),
219
+ ]
220
+ if native:
221
+ args.append("--native")
222
+ if idle:
223
+ args.append("--idle")
224
+ if gil:
225
+ args.append("--gil")
226
+ if subprocesses:
227
+ args.append("--subprocesses")
228
+
229
+ if pid:
230
+ args.extend(["--pid", str(pid)])
231
+ else:
232
+ args.append("--")
233
+ args.extend(command)
234
+
235
+ # top runs indefinitely; collect output for the requested duration then terminate.
236
+ binary = find_py_spy()
237
+ env = os.environ.copy()
238
+ env.setdefault("RUST_LOG", "warn")
239
+ proc = subprocess.Popen(
240
+ [binary, *args],
241
+ stdout=subprocess.PIPE,
242
+ stderr=subprocess.PIPE,
243
+ text=True,
244
+ encoding="utf-8",
245
+ errors="replace",
246
+ env=env,
247
+ )
248
+
249
+ from collections import deque
250
+ import threading
251
+ import time
252
+
253
+ lines: deque[str] = deque(maxlen=tail_lines)
254
+
255
+ def reader() -> None:
256
+ try:
257
+ for line in proc.stdout:
258
+ lines.append(line.rstrip("\n"))
259
+ except Exception:
260
+ pass
261
+
262
+ reader_thread = threading.Thread(target=reader, daemon=True)
263
+ reader_thread.start()
264
+
265
+ time.sleep(duration)
266
+ proc.terminate()
267
+ try:
268
+ proc.wait(timeout=5)
269
+ except subprocess.TimeoutExpired:
270
+ proc.kill()
271
+ proc.wait(timeout=5)
272
+
273
+ reader_thread.join(timeout=5)
274
+
275
+ if not lines and proc.stderr:
276
+ return proc.stderr.read()
277
+ return "\n".join(lines)
278
+
279
+
280
+ def _top_profile_windows(
281
+ pid: Optional[int],
282
+ command: Optional[List[str]],
283
+ duration: int,
284
+ rate: int,
285
+ native: bool,
286
+ idle: bool,
287
+ gil: bool,
288
+ subprocesses: bool,
289
+ top_n: int,
290
+ ) -> str:
291
+ """Windows fallback for top_profile using a short raw recording."""
292
+ fd, path = tempfile.mkstemp(suffix=".txt", prefix="pyspy_top_")
293
+ os.close(fd)
294
+ try:
295
+ args = [
296
+ "record",
297
+ "-o", path,
298
+ "--format", "raw",
299
+ "-d", str(duration),
300
+ "-r", str(rate),
301
+ ]
302
+ if native:
303
+ args.append("--native")
304
+ if idle:
305
+ args.append("--idle")
306
+ if gil:
307
+ args.append("--gil")
308
+ if subprocesses:
309
+ args.append("--subprocesses")
310
+
311
+ if pid:
312
+ args.extend(["--pid", str(pid)])
313
+ else:
314
+ args.append("--")
315
+ args.extend(command)
316
+
317
+ result = _run_py_spy(args, timeout=duration + 30)
318
+ if result.returncode != 0:
319
+ raise RuntimeError(
320
+ f"py-spy top (Windows fallback) failed (exit {result.returncode}):\n{result.stderr}"
321
+ )
322
+
323
+ frames = parser.parse_raw(path, top_n=top_n)
324
+ if not frames:
325
+ return "No samples collected."
326
+ return parser.format_hot_frames(frames)
327
+ finally:
328
+ try:
329
+ os.unlink(path)
330
+ except OSError:
331
+ pass
332
+