Skip to content

Commit ff9a880

Browse files
feat: Feature complete
1 parent 7671ac3 commit ff9a880

18 files changed

Lines changed: 306 additions & 35 deletions

README.md

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
# Experimental PHP SPX MCP Server 🚀
2+
3+
An MCP (Model Context Protocol) server for **Laravel applications** that brings the power of [PHP SPX profiler](https://github.com/NoiseByNorthwest/php-spx) to Claude Code. Analyze PHP application performance with AI assistance, getting intelligent insights into bottlenecks, memory usage, database queries, and more.
4+
5+
![License](https://img.shields.io/badge/license-MIT-blue.svg)
6+
![PHP Version](https://img.shields.io/badge/php-%5E8.1-blue)
7+
8+
## 🎯 What is This?
9+
10+
The PHP SPX MCP Server is a bridge between the powerful PHP SPX profiler and Claude Code (or any MCP-compatible AI client). It enables you to:
11+
12+
- 🔍 **Analyze performance profiles** with natural language queries
13+
- 📊 **Get AI-powered insights** into bottlenecks and optimization opportunities
14+
- 🎯 **Identify N+1 queries**, memory leaks, and slow functions automatically
15+
- 📈 **Visualize call trees**, execution timelines, and middleware chains
16+
- 💡 **Receive actionable recommendations** for performance improvements
17+
18+
Instead of manually digging through profiler data, simply ask Claude: *"What's causing the slowest response times?"* or *"Are there any N+1 query issues?"*
19+
20+
## 🛠️ Prerequisites
21+
22+
### PHP SPX Extension
23+
24+
You must have the PHP SPX extension installed and configured. Please refer to the official installation instructions at:
25+
26+
**[https://github.com/NoiseByNorthwest/php-spx](https://github.com/NoiseByNorthwest/php-spx)**
27+
28+
**Important:** Make sure to configure the `spx.data_dir` setting in your `php.ini`. This is where SPX will store profile data, and the MCP server needs to read from this location.
29+
30+
Example configuration:
31+
```ini
32+
extension=spx.so
33+
spx.data_dir="/tmp/spx"
34+
```
35+
36+
## 📦 Installation
37+
38+
Install the SPX MCP Server via Composer in your Laravel project:
39+
40+
```bash
41+
composer require codemonkey/spx-mcp-server --dev
42+
```
43+
44+
## ⚙️ Configuration
45+
46+
### Configure with Claude Code
47+
48+
The easiest way to configure the MCP server with Claude Code is using the built-in installation command:
49+
50+
```bash
51+
php artisan spx-mcp:install
52+
```
53+
54+
This will automatically add the SPX MCP server to your Claude Code configuration.
55+
56+
### Manual Configuration
57+
58+
Alternatively, you can manually add the server to your Claude Code configuration file (`~/.config/claude-code/config.json`):
59+
60+
```json
61+
{
62+
"mcpServers": {
63+
"spx-mcp": {
64+
"command": "php",
65+
"args": ["artisan", "spx-mcp:start"],
66+
"cwd": "/path/to/your/laravel/project"
67+
}
68+
}
69+
}
70+
```
71+
72+
### Environment Variables
73+
74+
You can customize the SPX MCP server behavior using environment variables in your `.env` file:
75+
76+
```env
77+
# Enable/disable the SPX MCP server
78+
SPX_MCP_ENABLED=true
79+
80+
# SPX data directory (should match your php.ini configuration)
81+
SPX_DATA_DIR=/tmp/spx
82+
```
83+
84+
You can also publish the configuration file for more control:
85+
86+
```bash
87+
php artisan vendor:publish --tag=spx-mcp-config
88+
```
89+
90+
## 🎮 Usage
91+
92+
### 1. Generate SPX Profiles
93+
94+
Generate performance profiles by triggering SPX during your application requests. Add the SPX trigger to your request URL:
95+
96+
```bash
97+
# Using the default key "dev"
98+
curl "http://your-app.test/your-endpoint?SPX_KEY=dev&SPX_UI_URI=/"
99+
```
100+
101+
Or use the SPX web UI to control profiling: `http://your-app.test/?SPX_KEY=dev&SPX_UI_URI=/`
102+
103+
Profiles will be saved to your configured `spx.data_dir`.
104+
105+
### 2. Analyze with Claude Code
106+
107+
Once you have generated profiles, open Claude Code and start asking questions:
108+
109+
**Example queries:**
110+
111+
- *"List all available SPX profiles"*
112+
- *"Analyze the latest profile and show me the slowest functions"*
113+
- *"Are there any N+1 database query issues?"*
114+
- *"What middleware is taking the most time?"*
115+
- *"Show me the call tree for the most recent profile"*
116+
- *"Which third-party packages are impacting performance?"*
117+
- *"Find any recursive functions and their overhead"*
118+
- *"What's the wall time vs CPU time distribution?"*
119+
120+
Claude will use the MCP tools to parse your SPX profiles and provide intelligent analysis and recommendations.
121+
122+
## 🧰 Available MCP Tools
123+
124+
The SPX MCP server provides the following analysis tools:
125+
126+
| Tool | Description |
127+
|------|-------------|
128+
| `list_profiles` | List all available SPX profiles |
129+
| `get_slowest_functions` | Get functions with highest exclusive execution time |
130+
| `get_most_called_functions` | Get most frequently called functions |
131+
| `get_memory_hogs` | Get functions using the most memory |
132+
| `get_cpu_intensive_functions` | Get CPU-intensive functions |
133+
| `get_call_tree` | Get hierarchical call tree structure |
134+
| `get_timeline_view` | Get chronological execution timeline |
135+
| `get_recursive_functions` | Identify recursive function calls |
136+
| `get_wall_time_distribution` | Analyze I/O vs CPU time distribution |
137+
| `get_autoloading_overhead` | Analyze autoloading performance impact |
138+
| `get_third_party_package_impact` | Analyze third-party package performance |
139+
| `get_database_queries` | Analyze database operations and detect N+1 queries |
140+
| `get_redis_operations` | Analyze Redis/cache operations |
141+
| `get_io_operations` | Analyze file, network, and socket I/O |
142+
| `get_middleware_analysis` | Analyze middleware execution order and timing |
143+
144+
## 📊 Example Analysis
145+
146+
```
147+
You: "Analyze the latest profile for performance issues"
148+
149+
Claude: I've analyzed your latest profile. Here are the key findings:
150+
151+
🔴 Critical Issues:
152+
1. N+1 Query Detected: User::with('posts') is executing 156 queries
153+
- Location: UserController::index
154+
- Impact: 2.3s of database time
155+
- Fix: Use eager loading with constraints
156+
157+
2. Slow Function: ImageProcessor::resize
158+
- Exclusive time: 1.8s
159+
- Called: 45 times
160+
- Recommendation: Consider async processing or caching
161+
162+
🟡 Optimization Opportunities:
163+
1. Autoloading overhead: 340ms across 89 class loads
164+
- Consider using composer dump-autoload -o
165+
166+
2. Middleware: AuthMiddleware taking 180ms
167+
- Session queries could be cached
168+
169+
📈 Overall Stats:
170+
- Total execution time: 4.2s
171+
- Peak memory: 45MB
172+
- Total function calls: 12,847
173+
```
174+
175+
## 🏗️ How It Works
176+
177+
1. **Profile Generation**: PHP SPX profiles your application and stores detailed execution traces
178+
2. **Profile Parsing**: The MCP server reads and parses SPX profile files (compressed `.txt.gz` format)
179+
3. **Data Analysis**: Profiles are analyzed to extract metrics like execution time, memory, call counts, etc.
180+
4. **MCP Tools**: Analysis results are exposed as MCP tools that Claude can invoke
181+
5. **AI Insights**: Claude uses the tools to answer your questions and provide recommendations
182+
183+
## 🔧 Development
184+
185+
### Running Tests
186+
187+
```bash
188+
./vendor/bin/pest
189+
```
190+
191+
### Project Structure
192+
193+
```
194+
spx-mcp-server/
195+
├── src/
196+
│ ├── Mcp/
197+
│ │ ├── SPX/
198+
│ │ │ └── ProfileParser.php # Core profile parsing logic
199+
│ │ ├── Tools/ # Individual MCP tools
200+
│ │ └── McpServer.php # MCP server implementation
201+
│ ├── Console/
202+
│ │ ├── StartCommand.php # Start MCP server command
203+
│ │ └── InstallCommand.php # Installation helper
204+
│ └── SPXMcpServiceProvider.php # Laravel service provider
205+
├── config/
206+
│ └── spx-mcp.php # Configuration file
207+
└── tests/ # Test suite
208+
```
209+
210+
## 🐛 Troubleshooting
211+
212+
**SPX profiles not being created?**
213+
- Verify SPX is installed: `php -m | grep spx`
214+
- Check your `php.ini` configuration
215+
- Ensure `spx.data_dir` is configured and writable
216+
- Refer to [PHP SPX documentation](https://github.com/NoiseByNorthwest/php-spx)
217+
218+
**MCP server not connecting?**
219+
- Verify the path in your Claude Code config is correct
220+
- Check that the server starts: `php artisan spx-mcp:start`
221+
- Review logs in `storage/logs/laravel.log`
222+
223+
**No profiles showing in Claude?**
224+
- Verify profiles exist in your SPX data directory
225+
- Check the `SPX_DATA_DIR` environment variable matches your `php.ini` setting
226+
- Ensure profiles have `.txt.gz` extension
227+
228+
## 📝 License
229+
230+
This package is open-sourced software licensed under the [MIT license](LICENSE).
231+
232+
## 🙏 Credits
233+
234+
- Built on [PHP SPX](https://github.com/NoiseByNorthwest/php-spx) by NoiseByNorthwest
235+
- Uses Laravel's [MCP package](https://github.com/laravel/mcp)
236+
- Created by [Mathias Hansen](https://codemonkey.io)
237+
238+
## 🤝 Contributing
239+
240+
Contributions are welcome! Please feel free to submit a Pull Request.
241+
242+
---
243+
244+
Made with ❤️ by developers who hate performance issues

src/Mcp/SPX/ProfileParser.php

Lines changed: 33 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,19 @@ class ProfileParser
1515
private array $recursionTracking = [];
1616

1717
/**
18-
* @param array $enabledMetrics Associative array of metric_key => bool from JSON metadata
18+
* @param array $enabledMetrics Array of metric names or associative array of metric_key => bool from JSON metadata
1919
*/
2020
public function __construct(array $enabledMetrics)
2121
{
2222
ini_set('memory_limit', '-1');
23-
$this->metrics = array_keys(array_filter($enabledMetrics));
23+
// Handle both plain array ["wt", "ct", ...] and associative array ["wt" => true, "ct" => false, ...]
24+
if (array_keys($enabledMetrics) === range(0, count($enabledMetrics) - 1)) {
25+
// Plain array - use directly
26+
$this->metrics = $enabledMetrics;
27+
} else {
28+
// Associative array - filter and extract keys
29+
$this->metrics = array_keys(array_filter($enabledMetrics));
30+
}
2431
}
2532

2633
protected function log(string $line): void
@@ -200,9 +207,10 @@ private function calculateStats(): void
200207
}
201208

202209
// Calculate inclusive metrics (total time/memory in function)
203-
$wallTime = ($event['metrics']['wt'] ?? 0) - ($frame['start_metrics']['wt'] ?? 0);
210+
// Note: SPX metrics are in nanoseconds, convert to microseconds
211+
$wallTime = (($event['metrics']['wt'] ?? 0) - ($frame['start_metrics']['wt'] ?? 0)) / 1000;
204212
$memory = ($event['metrics']['mu'] ?? 0) - ($frame['start_metrics']['mu'] ?? 0);
205-
$cpuTime = ($event['metrics']['ct'] ?? 0) - ($frame['start_metrics']['ct'] ?? 0);
213+
$cpuTime = (($event['metrics']['ct'] ?? 0) - ($frame['start_metrics']['ct'] ?? 0)) / 1000;
206214

207215
$this->functionStats[$funcIdx]['inclusive_time'] += $wallTime;
208216
$this->functionStats[$funcIdx]['inclusive_memory'] += $memory;
@@ -223,7 +231,7 @@ private function calculateStats(): void
223231

224232
// Update recursion tracking
225233
if (isset($this->recursionTracking[$funcIdx])) {
226-
$this->recursionTracking[$funcIdx]['total_time'] += $wallTime;
234+
$this->recursionTracking[$funcIdx]['total_time'] += $exclusiveTime;
227235
}
228236

229237
// Timeline entry
@@ -244,6 +252,17 @@ private function calculateStats(): void
244252
$callStack[$parentIdx]['children_time'] += $wallTime;
245253
$callStack[$parentIdx]['children_memory'] += $memory;
246254
$callStack[$parentIdx]['children_cpu'] += $cpuTime;
255+
256+
// Handle recursion: subtract exclusive time from recursive parent's children
257+
// This prevents double-counting in recursive scenarios
258+
for ($k = count($callStack) - 1; $k >= 0; $k--) {
259+
if ($callStack[$k]['func_idx'] === $funcIdx) {
260+
$callStack[$k]['children_time'] -= $exclusiveTime;
261+
$callStack[$k]['children_memory'] -= $exclusiveMemory;
262+
$callStack[$k]['children_cpu'] -= $exclusiveCpu;
263+
break;
264+
}
265+
}
247266
}
248267
}
249268
}
@@ -505,7 +524,7 @@ public function getAutoloadingOverhead(): array
505524
strpos($funcName, 'require') !== false ||
506525
strpos($funcName, 'spl_autoload_call') !== false) {
507526

508-
$autoloadStats['total_time'] += $stat['inclusive_time'];
527+
$autoloadStats['total_time'] += $stat['exclusive_time'];
509528
$autoloadStats['total_calls'] += $stat['call_count'];
510529
$autoloadStats['functions'][] = $stat;
511530
}
@@ -542,7 +561,7 @@ public function getThirdPartyPackageImpact(): array
542561
];
543562
}
544563

545-
$packages[$package]['total_time'] += $stat['inclusive_time'];
564+
$packages[$package]['total_time'] += $stat['exclusive_time'];
546565
$packages[$package]['total_calls'] += $stat['call_count'];
547566
$packages[$package]['functions'][] = $stat;
548567
}
@@ -582,7 +601,7 @@ public function getDatabaseQueries(): array
582601
strpos($funcName, 'PDO::') !== false ||
583602
strpos($funcName, 'mysqli') !== false) {
584603

585-
$queries['total_time'] += $stat['inclusive_time'];
604+
$queries['total_time'] += $stat['exclusive_time'];
586605
$queries['total_queries'] += $stat['call_count'];
587606

588607
// Categorize by operation type
@@ -633,7 +652,7 @@ public function getRedisOperations(): array
633652
stripos($funcName, 'Cache\\RedisStore') !== false ||
634653
stripos($funcName, 'Predis') !== false) {
635654

636-
$redis['total_time'] += $stat['inclusive_time'];
655+
$redis['total_time'] += $stat['exclusive_time'];
637656
$redis['total_operations'] += $stat['call_count'];
638657

639658
// Categorize by operation type
@@ -682,22 +701,22 @@ public function getIOOperations(): array
682701

683702
// File I/O
684703
if (preg_match('/^(file_|fread|fwrite|fopen|fclose|file_get_contents|file_put_contents|readfile|is_file|is_dir|scandir|glob)/i', $funcName)) {
685-
$io['file']['total_time'] += $stat['inclusive_time'];
704+
$io['file']['total_time'] += $stat['exclusive_time'];
686705
$io['file']['total_operations'] += $stat['call_count'];
687706
$io['file']['functions'][] = $stat;
688707
} // Network I/O
689708
elseif (stripos($funcName, 'curl_') !== false ||
690709
stripos($funcName, 'http') !== false ||
691710
stripos($funcName, 'guzzle') !== false ||
692711
stripos($funcName, 'RPC::call') !== false) {
693-
$io['network']['total_time'] += $stat['inclusive_time'];
712+
$io['network']['total_time'] += $stat['exclusive_time'];
694713
$io['network']['total_operations'] += $stat['call_count'];
695714
$io['network']['functions'][] = $stat;
696715
} // Socket I/O
697716
elseif (stripos($funcName, 'socket_') !== false ||
698717
stripos($funcName, 'SocketRelay') !== false ||
699718
stripos($funcName, 'stream_socket') !== false) {
700-
$io['socket']['total_time'] += $stat['inclusive_time'];
719+
$io['socket']['total_time'] += $stat['exclusive_time'];
701720
$io['socket']['total_operations'] += $stat['call_count'];
702721
$io['socket']['functions'][] = $stat;
703722
}
@@ -752,9 +771,9 @@ public function getMiddlewareAnalysis(): array
752771
// Find corresponding function stats
753772
if (isset($this->functionStats[$entry['func_idx']])) {
754773
$stat = $this->functionStats[$entry['func_idx']];
755-
$middleware['middleware_list'][$middlewareName]['total_time'] += $stat['inclusive_time'];
774+
$middleware['middleware_list'][$middlewareName]['total_time'] += $stat['exclusive_time'];
756775
$middleware['middleware_list'][$middlewareName]['call_count'] += $stat['call_count'];
757-
$middleware['total_time'] += $stat['inclusive_time'];
776+
$middleware['total_time'] += $stat['exclusive_time'];
758777
$middleware['total_calls'] += $stat['call_count'];
759778
}
760779

src/Mcp/Tools/GetAutoloadingOverhead.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,10 @@ public function handle(Request $request): Response
3636

3737
foreach (array_slice($autoloadStats['functions'], 0, $limit) as $func) {
3838
$output .= sprintf(" %s\n", substr($func['name'], 0, 50));
39-
$output .= sprintf(" Time: %s\n", $this->formatTime($func['inclusive_time']));
39+
$output .= sprintf(" Time: %s\n", $this->formatTime($func['exclusive_time']));
4040
$output .= sprintf(" Calls: %d\n", $func['call_count']);
4141
if ($func['call_count'] > 0) {
42-
$output .= sprintf(" Avg: %s\n", $this->formatTime($func['inclusive_time'] / $func['call_count']));
42+
$output .= sprintf(" Avg: %s\n", $this->formatTime($func['exclusive_time'] / $func['call_count']));
4343
}
4444
$output .= "\n";
4545
}

0 commit comments

Comments
 (0)