# Directory Structure
```
├── .gitignore
├── .python-version
├── LICENSE
├── pyproject.toml
├── README.md
├── server.py
└── uv.lock
```
# Files
--------------------------------------------------------------------------------
/.python-version:
--------------------------------------------------------------------------------
```
3.11
```
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
```
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
#uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# PyPI configuration file
.pypirc
# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info
# Virtual environments
.venv
```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
# Laravel Helpers MCP
⚠️ **ALPHA SOFTWARE WARNING** ⚠️
This package is currently in alpha stage. APIs and functionality may change without notice. Use in production at your own risk.
## Overview
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.
## Cursor Integration
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.
## Available Tools
- `tail_log_file`: View the most recent entries in your Laravel log file directly in Cursor
- `search_log_errors`: Search through your log files for specific error patterns with integrated results
- `run_artisan_command`: Execute Laravel artisan commands directly from Cursor
- `show_model`: Display model information and relationships in your editor
## Installation
1. Clone the repository:
```bash
git clone https://github.com/your-username/laravel-mcp.git
cd laravel-mcp
```
2. Create a shell script wrapper (e.g., `~/bin/run-laravel-mcp`):
```bash
#!/bin/bash
# Point to your Laravel project path
export LARAVEL_PATH=/path/to/your/laravel/project
# Run the MCP server
mcp run /path/to/laravel-helpers-mcp/server.py
```
3. Make the script executable:
```bash
chmod +x ~/bin/run-laravel-mcp
```
4. Ensure `~/bin` is in your PATH:
```bash
echo 'export PATH="$HOME/bin:$PATH"' >> ~/.zshrc # or ~/.bashrc
source ~/.zshrc # or source ~/.bashrc
```
## Requirements
- PHP 8.1+
- Laravel 10.0+
- [Cursor IDE](https://cursor.sh)
- [UV](https://github.com/astral-sh/uv) - Modern Python packaging tools
## Contributing
This project is in active development. Issues and pull requests are welcome.
## License
[License Type] - See LICENSE file for details
```
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
```toml
[project]
name = "laravel-mcp"
version = "0.0.1"
description = "Tools to help with Laravel development"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"mcp[cli]>=1.3.0",
]
```
--------------------------------------------------------------------------------
/server.py:
--------------------------------------------------------------------------------
```python
from mcp.server.fastmcp import FastMCP, Context
import os
import subprocess
import json
from datetime import datetime, timedelta
from typing import Optional, Union, List, Dict
import base64
from dataclasses import dataclass
import io
from pathlib import Path
import sys
import logging
import re
# Simple file logging
log_file = os.path.expanduser("~/laravel-helpers-mcp.log")
state_file = os.path.expanduser("~/laravel-helpers-state.json")
logging.basicConfig(
filename=log_file,
level=logging.DEBUG,
format='%(asctime)s - %(levelname)s - %(message)s'
)
def load_state() -> Dict[str, Union[int, float]]:
"""Load the file tracking state from disk"""
if os.path.exists(state_file):
try:
with open(state_file, 'r') as f:
return json.load(f)
except Exception as e:
logging.error(f"Failed to load state file: {e}")
return {'size': 0, 'mtime': 0.0, 'position': 0}
def save_state(state: Dict[str, Union[int, float]]) -> None:
"""Save the file tracking state to disk"""
try:
with open(state_file, 'w') as f:
json.dump(state, f)
except Exception as e:
logging.error(f"Failed to save state file: {e}")
# Store Laravel directory and file tracking info
laravel_dir: Optional[Path] = None
last_file_check = load_state()
def initialize_laravel_dir() -> None:
"""Initialize and validate the Laravel directory from LARAVEL_PATH env var"""
global laravel_dir, last_file_check
laravel_path = os.getenv('LARAVEL_PATH')
if not laravel_path:
error = "LARAVEL_PATH environment variable is not set"
logging.error(error)
raise ValueError(error)
laravel_dir = Path(os.path.expanduser(laravel_path))
logging.info(f"Initializing Laravel directory from LARAVEL_PATH: {laravel_dir}")
if not laravel_dir.exists():
error = f"Laravel directory not found: {laravel_dir}"
logging.error(error)
raise ValueError(error)
if not (laravel_dir / 'artisan').exists():
error = f"Not a valid Laravel directory (no artisan file found): {laravel_dir}"
logging.error(error)
raise ValueError(error)
# Initialize file tracking for the log file
log_path = laravel_dir / 'storage' / 'logs' / 'laravel.log'
if log_path.exists():
stats = log_path.stat()
last_file_check['size'] = stats.st_size
last_file_check['mtime'] = stats.st_mtime
last_file_check['position'] = stats.st_size
logging.info(f"Initialized file tracking: size={stats.st_size}, mtime={datetime.fromtimestamp(stats.st_mtime)}")
logging.info(f"Laravel directory validated successfully: {laravel_dir}")
# Initialize when module is loaded
initialize_laravel_dir()
# Initialize FastMCP with a descriptive name
mcp = FastMCP(
"Laravel Helper Tools",
description="Tools for working with Laravel applications"
)
@mcp.tool()
async def tail_log_file(ctx: Context, lines: int = 10) -> str:
"""Tail the last N lines of the Laravel log file
Args:
ctx: MCP context for logging and progress tracking
lines: Number of lines to tail from the log file
Returns:
The last N lines from the Laravel log file
"""
global laravel_dir
if laravel_dir is None:
error_msg = "Laravel directory not initialized"
logging.error(error_msg)
await ctx.error(error_msg)
return f"Error: {error_msg}"
logging.info(f"Tail log file called with lines={lines}")
log_path = laravel_dir / 'storage' / 'logs' / 'laravel.log'
if not log_path.exists():
error_msg = f"Log file not found at {log_path}"
logging.error(error_msg)
await ctx.error(error_msg)
return f"Error: {error_msg}"
try:
logging.debug(f"Reading last {lines} lines from {log_path}")
await ctx.info(f"Reading last {lines} lines from {log_path}")
result = subprocess.run(
["tail", f"-n{lines}", str(log_path)],
capture_output=True,
text=True,
check=True
)
logging.debug(f"Successfully read {lines} lines from log file")
return result.stdout
except subprocess.CalledProcessError as e:
error_msg = f"Error executing tail command: {str(e)}"
logging.error(f"Tail command failed: {str(e)}")
await ctx.error(error_msg)
return error_msg
@mcp.tool()
async def search_log_errors(
ctx: Context,
minutes_back: int = 1,
show_all: bool = False
) -> str:
"""Search the Laravel log file for errors within a specified time window
Args:
ctx: MCP context for logging and progress tracking
minutes_back: Number of minutes to look back for errors (default: 1, max: 60)
show_all: If True, show all errors in time window. If False, only show new errors since last check.
Returns:
Found error messages with timestamps
"""
global laravel_dir, last_file_check
# Validate minutes_back range
if minutes_back < 1 or minutes_back > 60:
error_msg = "minutes_back must be between 1 and 60"
logging.error(error_msg)
await ctx.error(error_msg)
return f"Error: {error_msg}"
if laravel_dir is None:
error_msg = "Laravel directory not initialized"
logging.error(error_msg)
await ctx.error(error_msg)
return f"Error: {error_msg}"
log_path = laravel_dir / 'storage' / 'logs' / 'laravel.log'
if not log_path.exists():
error_msg = f"Log file not found at {log_path}"
logging.error(error_msg)
await ctx.error(error_msg)
return f"Error: {error_msg}"
try:
# Check if file has been modified
stats = log_path.stat()
file_modified = (
stats.st_size != last_file_check['size'] or
stats.st_mtime > last_file_check['mtime']
)
if not file_modified and not show_all:
return "No new errors found (file unchanged)"
# Calculate the time window
now = datetime.now()
cutoff_time = now - timedelta(minutes=minutes_back)
logging.debug(f"Current time: {now}, Searching for errors between {cutoff_time} and {now}")
await ctx.info(f"Searching for errors in the last {minutes_back} minute(s)")
# If showing all errors, read from start of time window
# If showing only new, read from last position
if show_all:
# Use tail first to get recent content, then grep for errors
result = subprocess.run(
["tail", "-n", "1000", str(log_path)],
capture_output=True,
text=True,
check=True
)
# Now search this content for errors in our time window
content = result.stdout
else:
# Use tail to read only new content
bytes_to_read = stats.st_size - last_file_check['position']
if bytes_to_read > 0:
result = subprocess.run(
["tail", "-c", str(bytes_to_read), str(log_path)],
capture_output=True,
text=True,
check=True
)
content = result.stdout
else:
return "No new errors found (no new content)"
# Process the output to filter by timestamp and format nicely
errors = []
timestamp_pattern = r'\[([\d-]+ [\d:]+)\]'
for line in content.splitlines():
if 'ERROR:' not in line:
continue
# Extract timestamp
match = re.search(timestamp_pattern, line)
if not match:
continue
try:
# Parse the timestamp in the local timezone
timestamp = datetime.strptime(match.group(1), '%Y-%m-%d %H:%M:%S')
# Only include errors that are:
# 1. Not from the future
# 2. Within our time window
if timestamp <= now and timestamp >= cutoff_time:
# Format the error message nicely
errors.append(f"Time: {timestamp}\nError: {line.split('ERROR:', 1)[1].strip()}\n")
except ValueError:
# Skip lines with invalid timestamps
continue
# Update tracking info
if not show_all:
last_file_check['size'] = stats.st_size
last_file_check['mtime'] = stats.st_mtime
last_file_check['position'] = stats.st_size
logging.debug(f"Updated file tracking: size={stats.st_size}, mtime={datetime.fromtimestamp(stats.st_mtime)}")
if not errors:
return f"No {'new ' if not show_all else ''}errors found in the last {minutes_back} minute(s)"
# Sort errors by timestamp to show most recent first
errors.sort(reverse=True)
logging.debug(f"Found {len(errors)} errors")
return "\n".join(errors)
except subprocess.CalledProcessError as e:
error_msg = f"Error reading log file: {str(e)}"
logging.error(f"Command failed: {str(e)}")
await ctx.error(error_msg)
return error_msg
@mcp.tool()
async def run_artisan_command(ctx: Context, command: str) -> str:
"""Run an artisan command in the Laravel directory"""
try:
result = subprocess.run(
["php", "artisan"] + command.split(),
cwd=laravel_dir,
capture_output=True,
text=True,
check=True
)
return result.stdout
except subprocess.CalledProcessError as e:
error_msg = f"Error running artisan command: {e.stderr}"
logging.error(f"Artisan command failed: {error_msg}")
await ctx.error(error_msg)
return f"Error: {error_msg}"
@mcp.tool()
async def show_model(ctx: Context, model_name: str) -> str:
"""Show details about a Laravel model, focusing on relationships
Args:
ctx: MCP context for logging
model_name: Name of the model to inspect (e.g., 'User', 'Post')
Returns:
Model details with relationships highlighted
"""
logging.info(f"Showing model details for: {model_name}")
await ctx.info(f"Getting information about model: {model_name}")
# Run the model:show command
output = await run_artisan_command(ctx, f"model:show {model_name}")
# If there was an error, return it directly
if output.startswith("Error:"):
return output
# Process the output to highlight relationships
lines = output.splitlines()
formatted_lines = []
in_relations_section = False
for line in lines:
# Check for relationship methods
if any(rel in line.lower() for rel in ['hasone', 'hasmany', 'belongsto', 'belongstomany', 'hasmanythrough']):
# Add a blank line before relationships section if we just entered it
if not in_relations_section:
formatted_lines.append("\nRelationships:")
in_relations_section = True
# Clean up and format the relationship line
line = line.strip()
if line:
# Extract relationship type and related model
rel_match = re.search(r'(hasOne|hasMany|belongsTo|belongsToMany|hasManyThrough)\(([^)]+)\)', line)
if rel_match:
rel_type, rel_args = rel_match.groups()
formatted_lines.append(f"- {rel_type}: {rel_args}")
else:
formatted_lines.append(f"- {line}")
else:
# For non-relationship lines, just add them as-is
if line.strip():
formatted_lines.append(line)
# If we found no relationships, add a note
if not in_relations_section:
formatted_lines.append("\nNo relationships found in this model.")
return "\n".join(formatted_lines)
if __name__ == "__main__":
logging.info("Starting Laravel Helper Tools server")
mcp.run()
```