#
tokens: 5785/50000 5/5 files
lines: on (toggle) GitHub
raw markdown copy reset
# 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 | 
```