# Directory Structure
```
├── .gitignore
├── .python-version
├── LICENSE
├── pyproject.toml
├── README.md
├── server.py
└── uv.lock
```
# Files
--------------------------------------------------------------------------------
/.python-version:
--------------------------------------------------------------------------------
```
1 | 3.11
2 |
```
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
```
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | build/
12 | develop-eggs/
13 | dist/
14 | downloads/
15 | eggs/
16 | .eggs/
17 | lib/
18 | lib64/
19 | parts/
20 | sdist/
21 | var/
22 | wheels/
23 | share/python-wheels/
24 | *.egg-info/
25 | .installed.cfg
26 | *.egg
27 | MANIFEST
28 |
29 | # PyInstaller
30 | # Usually these files are written by a python script from a template
31 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
32 | *.manifest
33 | *.spec
34 |
35 | # Installer logs
36 | pip-log.txt
37 | pip-delete-this-directory.txt
38 |
39 | # Unit test / coverage reports
40 | htmlcov/
41 | .tox/
42 | .nox/
43 | .coverage
44 | .coverage.*
45 | .cache
46 | nosetests.xml
47 | coverage.xml
48 | *.cover
49 | *.py,cover
50 | .hypothesis/
51 | .pytest_cache/
52 | cover/
53 |
54 | # Translations
55 | *.mo
56 | *.pot
57 |
58 | # Django stuff:
59 | *.log
60 | local_settings.py
61 | db.sqlite3
62 | db.sqlite3-journal
63 |
64 | # Flask stuff:
65 | instance/
66 | .webassets-cache
67 |
68 | # Scrapy stuff:
69 | .scrapy
70 |
71 | # Sphinx documentation
72 | docs/_build/
73 |
74 | # PyBuilder
75 | .pybuilder/
76 | target/
77 |
78 | # Jupyter Notebook
79 | .ipynb_checkpoints
80 |
81 | # IPython
82 | profile_default/
83 | ipython_config.py
84 |
85 | # pyenv
86 | # For a library or package, you might want to ignore these files since the code is
87 | # intended to run in multiple environments; otherwise, check them in:
88 | # .python-version
89 |
90 | # pipenv
91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not
94 | # install all needed dependencies.
95 | #Pipfile.lock
96 |
97 | # UV
98 | # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
99 | # This is especially recommended for binary packages to ensure reproducibility, and is more
100 | # commonly ignored for libraries.
101 | #uv.lock
102 |
103 | # poetry
104 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
105 | # This is especially recommended for binary packages to ensure reproducibility, and is more
106 | # commonly ignored for libraries.
107 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
108 | #poetry.lock
109 |
110 | # pdm
111 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
112 | #pdm.lock
113 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
114 | # in version control.
115 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control
116 | .pdm.toml
117 | .pdm-python
118 | .pdm-build/
119 |
120 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
121 | __pypackages__/
122 |
123 | # Celery stuff
124 | celerybeat-schedule
125 | celerybeat.pid
126 |
127 | # SageMath parsed files
128 | *.sage.py
129 |
130 | # Environments
131 | .env
132 | .venv
133 | env/
134 | venv/
135 | ENV/
136 | env.bak/
137 | venv.bak/
138 |
139 | # Spyder project settings
140 | .spyderproject
141 | .spyproject
142 |
143 | # Rope project settings
144 | .ropeproject
145 |
146 | # mkdocs documentation
147 | /site
148 |
149 | # mypy
150 | .mypy_cache/
151 | .dmypy.json
152 | dmypy.json
153 |
154 | # Pyre type checker
155 | .pyre/
156 |
157 | # pytype static type analyzer
158 | .pytype/
159 |
160 | # Cython debug symbols
161 | cython_debug/
162 |
163 | # PyCharm
164 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
165 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
166 | # and can be added to the global gitignore or merged into this file. For a more nuclear
167 | # option (not recommended) you can uncomment the following to ignore the entire idea folder.
168 | #.idea/
169 |
170 | # PyPI configuration file
171 | .pypirc
172 | # Python-generated files
173 | __pycache__/
174 | *.py[oc]
175 | build/
176 | dist/
177 | wheels/
178 | *.egg-info
179 |
180 | # Virtual environments
181 | .venv
182 |
```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
1 | # Laravel Helpers MCP
2 |
3 | ⚠️ **ALPHA SOFTWARE WARNING** ⚠️
4 | This package is currently in alpha stage. APIs and functionality may change without notice. Use in production at your own risk.
5 |
6 | ## Overview
7 | A collection of Laravel helper tools specifically designed for integration with [Cursor IDE](https://cursor.sh), improving development workflow and debugging capabilities directly within your editor.
8 |
9 | ## Cursor Integration
10 | This package is built to enhance your Laravel development experience in Cursor IDE. All tools are accessible directly through Cursor's command palette and integrate seamlessly with your development workflow.
11 |
12 | ## Available Tools
13 |
14 | - `tail_log_file`: View the most recent entries in your Laravel log file directly in Cursor
15 | - `search_log_errors`: Search through your log files for specific error patterns with integrated results
16 | - `run_artisan_command`: Execute Laravel artisan commands directly from Cursor
17 | - `show_model`: Display model information and relationships in your editor
18 |
19 | ## Installation
20 |
21 | 1. Clone the repository:
22 | ```bash
23 | git clone https://github.com/your-username/laravel-mcp.git
24 | cd laravel-mcp
25 | ```
26 |
27 | 2. Create a shell script wrapper (e.g., `~/bin/run-laravel-mcp`):
28 | ```bash
29 | #!/bin/bash
30 |
31 | # Point to your Laravel project path
32 | export LARAVEL_PATH=/path/to/your/laravel/project
33 |
34 | # Run the MCP server
35 | mcp run /path/to/laravel-helpers-mcp/server.py
36 | ```
37 |
38 | 3. Make the script executable:
39 | ```bash
40 | chmod +x ~/bin/run-laravel-mcp
41 | ```
42 |
43 | 4. Ensure `~/bin` is in your PATH:
44 | ```bash
45 | echo 'export PATH="$HOME/bin:$PATH"' >> ~/.zshrc # or ~/.bashrc
46 | source ~/.zshrc # or source ~/.bashrc
47 | ```
48 |
49 | ## Requirements
50 | - PHP 8.1+
51 | - Laravel 10.0+
52 | - [Cursor IDE](https://cursor.sh)
53 | - [UV](https://github.com/astral-sh/uv) - Modern Python packaging tools
54 |
55 | ## Contributing
56 | This project is in active development. Issues and pull requests are welcome.
57 |
58 | ## License
59 | [License Type] - See LICENSE file for details
60 |
```
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
```toml
1 | [project]
2 | name = "laravel-mcp"
3 | version = "0.0.1"
4 | description = "Tools to help with Laravel development"
5 | readme = "README.md"
6 | requires-python = ">=3.11"
7 | dependencies = [
8 | "mcp[cli]>=1.3.0",
9 | ]
10 |
```
--------------------------------------------------------------------------------
/server.py:
--------------------------------------------------------------------------------
```python
1 | from mcp.server.fastmcp import FastMCP, Context
2 | import os
3 | import subprocess
4 | import json
5 | from datetime import datetime, timedelta
6 | from typing import Optional, Union, List, Dict
7 | import base64
8 | from dataclasses import dataclass
9 | import io
10 | from pathlib import Path
11 | import sys
12 | import logging
13 | import re
14 |
15 | # Simple file logging
16 | log_file = os.path.expanduser("~/laravel-helpers-mcp.log")
17 | state_file = os.path.expanduser("~/laravel-helpers-state.json")
18 | logging.basicConfig(
19 | filename=log_file,
20 | level=logging.DEBUG,
21 | format='%(asctime)s - %(levelname)s - %(message)s'
22 | )
23 |
24 | def load_state() -> Dict[str, Union[int, float]]:
25 | """Load the file tracking state from disk"""
26 | if os.path.exists(state_file):
27 | try:
28 | with open(state_file, 'r') as f:
29 | return json.load(f)
30 | except Exception as e:
31 | logging.error(f"Failed to load state file: {e}")
32 | return {'size': 0, 'mtime': 0.0, 'position': 0}
33 |
34 | def save_state(state: Dict[str, Union[int, float]]) -> None:
35 | """Save the file tracking state to disk"""
36 | try:
37 | with open(state_file, 'w') as f:
38 | json.dump(state, f)
39 | except Exception as e:
40 | logging.error(f"Failed to save state file: {e}")
41 |
42 | # Store Laravel directory and file tracking info
43 | laravel_dir: Optional[Path] = None
44 | last_file_check = load_state()
45 |
46 | def initialize_laravel_dir() -> None:
47 | """Initialize and validate the Laravel directory from LARAVEL_PATH env var"""
48 | global laravel_dir, last_file_check
49 |
50 | laravel_path = os.getenv('LARAVEL_PATH')
51 | if not laravel_path:
52 | error = "LARAVEL_PATH environment variable is not set"
53 | logging.error(error)
54 | raise ValueError(error)
55 |
56 | laravel_dir = Path(os.path.expanduser(laravel_path))
57 | logging.info(f"Initializing Laravel directory from LARAVEL_PATH: {laravel_dir}")
58 |
59 | if not laravel_dir.exists():
60 | error = f"Laravel directory not found: {laravel_dir}"
61 | logging.error(error)
62 | raise ValueError(error)
63 |
64 | if not (laravel_dir / 'artisan').exists():
65 | error = f"Not a valid Laravel directory (no artisan file found): {laravel_dir}"
66 | logging.error(error)
67 | raise ValueError(error)
68 |
69 | # Initialize file tracking for the log file
70 | log_path = laravel_dir / 'storage' / 'logs' / 'laravel.log'
71 | if log_path.exists():
72 | stats = log_path.stat()
73 | last_file_check['size'] = stats.st_size
74 | last_file_check['mtime'] = stats.st_mtime
75 | last_file_check['position'] = stats.st_size
76 | logging.info(f"Initialized file tracking: size={stats.st_size}, mtime={datetime.fromtimestamp(stats.st_mtime)}")
77 |
78 | logging.info(f"Laravel directory validated successfully: {laravel_dir}")
79 |
80 | # Initialize when module is loaded
81 | initialize_laravel_dir()
82 |
83 | # Initialize FastMCP with a descriptive name
84 | mcp = FastMCP(
85 | "Laravel Helper Tools",
86 | description="Tools for working with Laravel applications"
87 | )
88 |
89 |
90 | @mcp.tool()
91 | async def tail_log_file(ctx: Context, lines: int = 10) -> str:
92 | """Tail the last N lines of the Laravel log file
93 |
94 | Args:
95 | ctx: MCP context for logging and progress tracking
96 | lines: Number of lines to tail from the log file
97 |
98 | Returns:
99 | The last N lines from the Laravel log file
100 | """
101 | global laravel_dir
102 | if laravel_dir is None:
103 | error_msg = "Laravel directory not initialized"
104 | logging.error(error_msg)
105 | await ctx.error(error_msg)
106 | return f"Error: {error_msg}"
107 |
108 | logging.info(f"Tail log file called with lines={lines}")
109 | log_path = laravel_dir / 'storage' / 'logs' / 'laravel.log'
110 |
111 | if not log_path.exists():
112 | error_msg = f"Log file not found at {log_path}"
113 | logging.error(error_msg)
114 | await ctx.error(error_msg)
115 | return f"Error: {error_msg}"
116 |
117 | try:
118 | logging.debug(f"Reading last {lines} lines from {log_path}")
119 | await ctx.info(f"Reading last {lines} lines from {log_path}")
120 | result = subprocess.run(
121 | ["tail", f"-n{lines}", str(log_path)],
122 | capture_output=True,
123 | text=True,
124 | check=True
125 | )
126 | logging.debug(f"Successfully read {lines} lines from log file")
127 | return result.stdout
128 | except subprocess.CalledProcessError as e:
129 | error_msg = f"Error executing tail command: {str(e)}"
130 | logging.error(f"Tail command failed: {str(e)}")
131 | await ctx.error(error_msg)
132 | return error_msg
133 |
134 | @mcp.tool()
135 | async def search_log_errors(
136 | ctx: Context,
137 | minutes_back: int = 1,
138 | show_all: bool = False
139 | ) -> str:
140 | """Search the Laravel log file for errors within a specified time window
141 |
142 | Args:
143 | ctx: MCP context for logging and progress tracking
144 | minutes_back: Number of minutes to look back for errors (default: 1, max: 60)
145 | show_all: If True, show all errors in time window. If False, only show new errors since last check.
146 |
147 | Returns:
148 | Found error messages with timestamps
149 | """
150 | global laravel_dir, last_file_check
151 |
152 | # Validate minutes_back range
153 | if minutes_back < 1 or minutes_back > 60:
154 | error_msg = "minutes_back must be between 1 and 60"
155 | logging.error(error_msg)
156 | await ctx.error(error_msg)
157 | return f"Error: {error_msg}"
158 |
159 | if laravel_dir is None:
160 | error_msg = "Laravel directory not initialized"
161 | logging.error(error_msg)
162 | await ctx.error(error_msg)
163 | return f"Error: {error_msg}"
164 |
165 | log_path = laravel_dir / 'storage' / 'logs' / 'laravel.log'
166 | if not log_path.exists():
167 | error_msg = f"Log file not found at {log_path}"
168 | logging.error(error_msg)
169 | await ctx.error(error_msg)
170 | return f"Error: {error_msg}"
171 |
172 | try:
173 | # Check if file has been modified
174 | stats = log_path.stat()
175 | file_modified = (
176 | stats.st_size != last_file_check['size'] or
177 | stats.st_mtime > last_file_check['mtime']
178 | )
179 |
180 | if not file_modified and not show_all:
181 | return "No new errors found (file unchanged)"
182 |
183 | # Calculate the time window
184 | now = datetime.now()
185 | cutoff_time = now - timedelta(minutes=minutes_back)
186 | logging.debug(f"Current time: {now}, Searching for errors between {cutoff_time} and {now}")
187 | await ctx.info(f"Searching for errors in the last {minutes_back} minute(s)")
188 |
189 | # If showing all errors, read from start of time window
190 | # If showing only new, read from last position
191 | if show_all:
192 | # Use tail first to get recent content, then grep for errors
193 | result = subprocess.run(
194 | ["tail", "-n", "1000", str(log_path)],
195 | capture_output=True,
196 | text=True,
197 | check=True
198 | )
199 |
200 | # Now search this content for errors in our time window
201 | content = result.stdout
202 | else:
203 | # Use tail to read only new content
204 | bytes_to_read = stats.st_size - last_file_check['position']
205 | if bytes_to_read > 0:
206 | result = subprocess.run(
207 | ["tail", "-c", str(bytes_to_read), str(log_path)],
208 | capture_output=True,
209 | text=True,
210 | check=True
211 | )
212 | content = result.stdout
213 | else:
214 | return "No new errors found (no new content)"
215 |
216 | # Process the output to filter by timestamp and format nicely
217 | errors = []
218 | timestamp_pattern = r'\[([\d-]+ [\d:]+)\]'
219 |
220 | for line in content.splitlines():
221 | if 'ERROR:' not in line:
222 | continue
223 |
224 | # Extract timestamp
225 | match = re.search(timestamp_pattern, line)
226 | if not match:
227 | continue
228 |
229 | try:
230 | # Parse the timestamp in the local timezone
231 | timestamp = datetime.strptime(match.group(1), '%Y-%m-%d %H:%M:%S')
232 |
233 | # Only include errors that are:
234 | # 1. Not from the future
235 | # 2. Within our time window
236 | if timestamp <= now and timestamp >= cutoff_time:
237 | # Format the error message nicely
238 | errors.append(f"Time: {timestamp}\nError: {line.split('ERROR:', 1)[1].strip()}\n")
239 | except ValueError:
240 | # Skip lines with invalid timestamps
241 | continue
242 |
243 | # Update tracking info
244 | if not show_all:
245 | last_file_check['size'] = stats.st_size
246 | last_file_check['mtime'] = stats.st_mtime
247 | last_file_check['position'] = stats.st_size
248 | logging.debug(f"Updated file tracking: size={stats.st_size}, mtime={datetime.fromtimestamp(stats.st_mtime)}")
249 |
250 | if not errors:
251 | return f"No {'new ' if not show_all else ''}errors found in the last {minutes_back} minute(s)"
252 |
253 | # Sort errors by timestamp to show most recent first
254 | errors.sort(reverse=True)
255 | logging.debug(f"Found {len(errors)} errors")
256 | return "\n".join(errors)
257 |
258 | except subprocess.CalledProcessError as e:
259 | error_msg = f"Error reading log file: {str(e)}"
260 | logging.error(f"Command failed: {str(e)}")
261 | await ctx.error(error_msg)
262 | return error_msg
263 |
264 | @mcp.tool()
265 | async def run_artisan_command(ctx: Context, command: str) -> str:
266 | """Run an artisan command in the Laravel directory"""
267 | try:
268 | result = subprocess.run(
269 | ["php", "artisan"] + command.split(),
270 | cwd=laravel_dir,
271 | capture_output=True,
272 | text=True,
273 | check=True
274 | )
275 | return result.stdout
276 | except subprocess.CalledProcessError as e:
277 | error_msg = f"Error running artisan command: {e.stderr}"
278 | logging.error(f"Artisan command failed: {error_msg}")
279 | await ctx.error(error_msg)
280 | return f"Error: {error_msg}"
281 |
282 | @mcp.tool()
283 | async def show_model(ctx: Context, model_name: str) -> str:
284 | """Show details about a Laravel model, focusing on relationships
285 |
286 | Args:
287 | ctx: MCP context for logging
288 | model_name: Name of the model to inspect (e.g., 'User', 'Post')
289 |
290 | Returns:
291 | Model details with relationships highlighted
292 | """
293 | logging.info(f"Showing model details for: {model_name}")
294 | await ctx.info(f"Getting information about model: {model_name}")
295 |
296 | # Run the model:show command
297 | output = await run_artisan_command(ctx, f"model:show {model_name}")
298 |
299 | # If there was an error, return it directly
300 | if output.startswith("Error:"):
301 | return output
302 |
303 | # Process the output to highlight relationships
304 | lines = output.splitlines()
305 | formatted_lines = []
306 | in_relations_section = False
307 |
308 | for line in lines:
309 | # Check for relationship methods
310 | if any(rel in line.lower() for rel in ['hasone', 'hasmany', 'belongsto', 'belongstomany', 'hasmanythrough']):
311 | # Add a blank line before relationships section if we just entered it
312 | if not in_relations_section:
313 | formatted_lines.append("\nRelationships:")
314 | in_relations_section = True
315 |
316 | # Clean up and format the relationship line
317 | line = line.strip()
318 | if line:
319 | # Extract relationship type and related model
320 | rel_match = re.search(r'(hasOne|hasMany|belongsTo|belongsToMany|hasManyThrough)\(([^)]+)\)', line)
321 | if rel_match:
322 | rel_type, rel_args = rel_match.groups()
323 | formatted_lines.append(f"- {rel_type}: {rel_args}")
324 | else:
325 | formatted_lines.append(f"- {line}")
326 | else:
327 | # For non-relationship lines, just add them as-is
328 | if line.strip():
329 | formatted_lines.append(line)
330 |
331 | # If we found no relationships, add a note
332 | if not in_relations_section:
333 | formatted_lines.append("\nNo relationships found in this model.")
334 |
335 | return "\n".join(formatted_lines)
336 |
337 | if __name__ == "__main__":
338 | logging.info("Starting Laravel Helper Tools server")
339 | mcp.run()
340 |
341 |
```