├── .gitignore ├── .rubocop.yml ├── CHANGELOG.md ├── Gemfile ├── Gemfile.lock ├── LICENSE.txt ├── README.md ├── Rakefile ├── config └── resources.yml ├── docs └── RESOURCES.md ├── exe ├── rails-mcp-server ├── rails-mcp-server-download-resources └── rails-mcp-setup-claude ├── lib ├── rails-mcp-server │ ├── config.rb │ ├── extensions │ │ ├── resource_templating.rb │ │ └── server_templating.rb │ ├── helpers │ │ ├── resource_base.rb │ │ ├── resource_downloader.rb │ │ └── resource_importer.rb │ ├── resources │ │ ├── base_resource.rb │ │ ├── custom_guides_resource.rb │ │ ├── custom_guides_resources.rb │ │ ├── guide_content_formatter.rb │ │ ├── guide_error_handler.rb │ │ ├── guide_file_finder.rb │ │ ├── guide_framework_contract.rb │ │ ├── guide_loader_template.rb │ │ ├── guide_manifest_operations.rb │ │ ├── kamal_guides_resource.rb │ │ ├── kamal_guides_resources.rb │ │ ├── rails_guides_resource.rb │ │ ├── rails_guides_resources.rb │ │ ├── stimulus_guides_resource.rb │ │ ├── stimulus_guides_resources.rb │ │ ├── turbo_guides_resource.rb │ │ └── turbo_guides_resources.rb │ ├── tools │ │ ├── analyze_controller_views.rb │ │ ├── analyze_environment_config.rb │ │ ├── analyze_models.rb │ │ ├── base_tool.rb │ │ ├── get_file.rb │ │ ├── get_model.rb │ │ ├── get_routes.rb │ │ ├── get_schema.rb │ │ ├── list_files.rb │ │ ├── load_guide.rb │ │ ├── project_info.rb │ │ └── switch_project.rb │ ├── utilities │ │ └── run_process.rb │ └── version.rb └── rails_mcp_server.rb └── rails-mcp-server.gemspec /.gitignore: -------------------------------------------------------------------------------- 1 | pkg/**/* 2 | tmp/**/* 3 | .DS_Store 4 | bin/changelog_generator.rb 5 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | require: 2 | - standard 3 | 4 | inherit_gem: 5 | standard: config/base.yml 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## 1.2.1 - 2025-06-09 9 | 10 | ### Fixed 11 | 12 | - Fix a debug message generating invalid STDIO output. 13 | - Fix tool name spelling. 14 | 15 | ## [1.2.0] - 2025-06-03 16 | 17 | ### Added 18 | 19 | - **Comprehensive Resources and Documentation System**: Complete access to Rails ecosystem documentation 20 | - `load_guide` tool for accessing official framework documentation 21 | - Support for Rails, Turbo, Stimulus, and Kamal documentation 22 | - Custom markdown file import capabilities 23 | - Automatic resource downloading via `rails-mcp-server-download-resources` command 24 | - Resource storage in XDG-compliant directories with manifest tracking 25 | - **Five Resource Categories**: 26 | - **Rails Guides**: Official Ruby on Rails 8.0.2 documentation (50+ guides) 27 | - **Turbo Guides**: Complete Hotwire Turbo framework documentation 28 | - **Stimulus Guides**: Full Stimulus JavaScript framework documentation 29 | - **Kamal Guides**: Comprehensive Kamal deployment tool documentation 30 | - **Custom Guides**: Import and manage your own markdown documentation 31 | - **Direct Resource Access**: MCP clients can query resources using URI templates 32 | - `rails://guides/{guide_name}` for Rails documentation 33 | - `turbo://guides/{guide_name}` for Turbo guides 34 | - `stimulus://guides/{guide_name}` for Stimulus documentation 35 | - `kamal://guides/{guide_name}` for Kamal guides 36 | - `custom://guides/{guide_name}` for custom imports 37 | - **Advanced Resource Management**: 38 | - Force download options (`--force`) for updating resources 39 | - Verbose logging (`--verbose`) for troubleshooting 40 | - Batch import support for directory structures 41 | - Filename normalization for custom imports 42 | - Version tracking and update management 43 | 44 | ### Improved 45 | 46 | - **Enhanced Documentation**: All tools now include comprehensive natural language examples 47 | - **Resource Integration**: Seamless integration with existing MCP tool ecosystem 48 | - **File Organization**: Better project structure with XDG Base Directory compliance 49 | - **Error Handling**: Improved validation and error messages across resource operations 50 | 51 | ### Technical 52 | 53 | - URI templating support for direct resource access 54 | - Enhanced manifest management for resource tracking 55 | - Integration with FastMCP for robust resource handling 56 | - Simplified guides configuration and management 57 | 58 | ## [1.1.4] - 2025-05-02 59 | 60 | ### Fixed 61 | 62 | - **HTTP Mode Issues**: Resolved Puma server startup failures in HTTP mode 63 | - **Rails Command Execution**: Fixed issues with executing Rails commands in certain environments 64 | - **Logger Configuration**: Fixed logger initialization when no config file exists (thanks to @justwiebe) 65 | 66 | ### Changed 67 | 68 | - Improved dependency version constraints for better compatibility 69 | - Enhanced error handling for HTTP server operations 70 | 71 | ## [1.1.0] - 2025-04-20 72 | 73 | ### Added 74 | 75 | - **HTTP Server-Sent Events (SSE) Support**: Real-time communication capabilities 76 | - HTTP mode with JSON-RPC and SSE endpoints (`--mode http`) 77 | - Custom port configuration with `-p/--port` option 78 | - SSE endpoint at `/mcp/sse` for real-time updates 79 | - JSON-RPC endpoint at `/mcp/messages` 80 | - **New Analysis Tools**: 81 | - `analyze_controller_views`: Analyze relationships between controllers, actions, and views 82 | - `analyze_environment_config`: Compare environment configurations for inconsistencies and security issues 83 | - **Enhanced Integration**: 84 | - MCP Inspector compatibility for testing and debugging 85 | - Improved client compatibility through HTTP endpoints 86 | - Better support for multiple LLM clients 87 | 88 | ### Improved 89 | 90 | - **Code Organization**: Major refactoring using FastMCP gem 91 | - Transition from mcp-rb to fast-mcp for better protocol support 92 | - Enhanced modular architecture for better maintainability 93 | - Improved error handling and validation across all tools 94 | - **File System Respect**: All tools now respect `.gitignore` files when scanning codebases 95 | - **Tool Enhancements**: Comprehensive review and improvement of all existing tools 96 | - Better edge case handling for large projects 97 | - Performance improvements for file operations 98 | - Enhanced logging and debugging capabilities 99 | 100 | ### Technical 101 | 102 | - **FastMCP Integration**: Complete migration to FastMCP gem for robust MCP protocol implementation 103 | - **Rack-based Architecture**: HTTP server implementation using Rack 104 | - **Event-driven Communication**: Real-time event handling for SSE support 105 | - **Improved Connection Management**: Better handling of client connections and message processing 106 | 107 | ### Changed 108 | 109 | - **Internal Architecture**: Complete refactoring to use FastMCP instead of mcp-rb 110 | - **Command-line Interface**: Enhanced CLI with mode selection and configuration options 111 | - **Documentation**: Updated with detailed tool descriptions and usage examples 112 | 113 | ## [1.0.0] - 2025-03-20 114 | 115 | ### Added 116 | 117 | - **Initial Release**: Rails MCP Server for AI-assisted Rails development 118 | - **Core Tools** (8 tools): 119 | - `switch_project`: Change active Rails project for multi-project support 120 | - `project_info`: Get comprehensive Rails project information and structure 121 | - `list_files`: Browse project files with glob pattern matching 122 | - `get_file`: Retrieve file contents with syntax highlighting 123 | - `get_routes`: Display all Rails routes (equivalent to `rails routes`) 124 | - `analyze_models`: Analyze Active Record models, associations, and relationships 125 | - `get_schema`: Retrieve database schema information and table structures 126 | - Basic file and project analysis capabilities 127 | - **Project Management System**: 128 | - Multi-project support through `projects.yml` configuration 129 | - XDG Base Directory Specification compliance for configuration 130 | - Automatic project detection and setup 131 | - Cross-platform compatibility (macOS, Windows) 132 | - **Claude Desktop Integration**: 133 | - STDIO mode communication for direct integration 134 | - Automatic setup script (`rails-mcp-setup-claude`) 135 | - Ruby version manager compatibility documentation 136 | - JSON-RPC 2.0 protocol implementation 137 | - **Developer Experience**: 138 | - Comprehensive logging system with configurable levels 139 | - Debug mode and verbose output options 140 | - Robust error handling and validation 141 | - Clear documentation and setup guides 142 | 143 | ### Features 144 | 145 | - **Rails-Specific Intelligence**: Deep understanding of Rails application architecture 146 | - Model relationships and Active Record associations analysis 147 | - Controller and route mapping 148 | - Database schema exploration and relationship discovery 149 | - Rails convention-based file discovery 150 | - **Smart File System Integration**: 151 | - Intelligent file discovery with glob pattern support 152 | - Automatic respect for Rails conventions and `.gitignore` 153 | - Syntax highlighting for various file types 154 | - Efficient handling of large codebases 155 | - **Configuration Management**: 156 | - Platform-specific configuration directories 157 | - Project-based organization and switching 158 | - Automatic initialization and setup 159 | - Secure and isolated project environments 160 | 161 | ### Technical Foundation 162 | 163 | - **Model Context Protocol (MCP)**: Full MCP specification implementation 164 | - **Communication**: JSON-RPC 2.0 protocol over STDIO 165 | - **Architecture**: Modular, extensible design for future enhancements 166 | - **Distribution**: Ruby gem packaging for easy installation and updates 167 | - **Compatibility**: Cross-platform support with platform-specific optimizations 168 | 169 | --- 170 | 171 | ## Version History Summary 172 | 173 | - **v1.2.0** (2025-06-03): Major documentation and resource system addition 174 | - **v1.1.4** (2025-05-02): Critical bug fixes for HTTP mode and Rails commands 175 | - **v1.1.0** (2025-04-20): HTTP SSE support and new analysis tools with FastMCP migration 176 | - **v1.0.0** (2025-03-20): Initial release with core Rails MCP functionality 177 | 178 | ## Development Guidelines 179 | 180 | ### Version Numbering 181 | 182 | This project follows [Semantic Versioning](https://semver.org/): 183 | 184 | - **MAJOR** version for incompatible API changes 185 | - **MINOR** version for backwards-compatible functionality additions 186 | - **PATCH** version for backwards-compatible bug fixes 187 | 188 | ### Release Process 189 | 190 | 1. Update version in `lib/rails-mcp-server/version.rb` 191 | 2. Update this CHANGELOG.md with new features and changes 192 | 3. Update README.md and documentation if needed 193 | 4. Create a git tag: `git tag -a v1.2.0 -m "Release version 1.2.0"` 194 | 5. Push tags: `git push origin --tags` 195 | 6. Build and publish gem: `gem build rails-mcp-server.gemspec && gem push rails-mcp-server-1.2.0.gem` 196 | 197 | ### Changelog Categories 198 | 199 | - **Added** for new features 200 | - **Changed** for changes in existing functionality 201 | - **Deprecated** for soon-to-be removed features 202 | - **Removed** for now removed features 203 | - **Fixed** for any bug fixes 204 | - **Security** for vulnerability fixes 205 | - **Technical** for internal/technical improvements 206 | - **Improved** for enhancements to existing features 207 | 208 | --- 209 | 210 | ## Links 211 | 212 | - [Repository](https://github.com/maquina-app/rails-mcp-server) 213 | - [Releases](https://github.com/maquina-app/rails-mcp-server/releases) 214 | - [Issues](https://github.com/maquina-app/rails-mcp-server/issues) 215 | - [Documentation](https://github.com/maquina-app/rails-mcp-server#readme) 216 | - [Resources Guide](docs/RESOURCES.md) 217 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | # Specify your gem's dependencies in rails-mcp-server.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | rails-mcp-server (1.2.1) 5 | addressable (~> 2.8) 6 | fast-mcp (~> 1.4.0) 7 | logger (~> 1.7.0) 8 | puma (~> 6.6.0) 9 | rack (~> 3.1.12) 10 | 11 | GEM 12 | remote: https://rubygems.org/ 13 | specs: 14 | addressable (2.8.7) 15 | public_suffix (>= 2.0.2, < 7.0) 16 | ast (2.4.3) 17 | base64 (0.3.0) 18 | bigdecimal (3.2.2) 19 | concurrent-ruby (1.3.5) 20 | dry-configurable (1.3.0) 21 | dry-core (~> 1.1) 22 | zeitwerk (~> 2.6) 23 | dry-core (1.1.0) 24 | concurrent-ruby (~> 1.0) 25 | logger 26 | zeitwerk (~> 2.6) 27 | dry-inflector (1.2.0) 28 | dry-initializer (3.2.0) 29 | dry-logic (1.6.0) 30 | bigdecimal 31 | concurrent-ruby (~> 1.0) 32 | dry-core (~> 1.1) 33 | zeitwerk (~> 2.6) 34 | dry-schema (1.14.1) 35 | concurrent-ruby (~> 1.0) 36 | dry-configurable (~> 1.0, >= 1.0.1) 37 | dry-core (~> 1.1) 38 | dry-initializer (~> 3.2) 39 | dry-logic (~> 1.5) 40 | dry-types (~> 1.8) 41 | zeitwerk (~> 2.6) 42 | dry-types (1.8.3) 43 | bigdecimal (~> 3.0) 44 | concurrent-ruby (~> 1.0) 45 | dry-core (~> 1.0) 46 | dry-inflector (~> 1.0) 47 | dry-logic (~> 1.4) 48 | zeitwerk (~> 2.6) 49 | fast-mcp (1.4.0) 50 | base64 51 | dry-schema (~> 1.14) 52 | json (~> 2.0) 53 | mime-types (~> 3.4) 54 | rack (~> 3.1) 55 | json (2.12.2) 56 | language_server-protocol (3.17.0.5) 57 | lint_roller (1.1.0) 58 | logger (1.7.0) 59 | mime-types (3.7.0) 60 | logger 61 | mime-types-data (~> 3.2025, >= 3.2025.0507) 62 | mime-types-data (3.2025.0603) 63 | nio4r (2.7.4) 64 | parallel (1.27.0) 65 | parser (3.3.8.0) 66 | ast (~> 2.4.1) 67 | racc 68 | prism (1.4.0) 69 | public_suffix (6.0.2) 70 | puma (6.6.0) 71 | nio4r (~> 2.0) 72 | racc (1.8.1) 73 | rack (3.1.16) 74 | rainbow (3.1.1) 75 | regexp_parser (2.10.0) 76 | rubocop (1.75.8) 77 | json (~> 2.3) 78 | language_server-protocol (~> 3.17.0.2) 79 | lint_roller (~> 1.1.0) 80 | parallel (~> 1.10) 81 | parser (>= 3.3.0.2) 82 | rainbow (>= 2.2.2, < 4.0) 83 | regexp_parser (>= 2.9.3, < 3.0) 84 | rubocop-ast (>= 1.44.0, < 2.0) 85 | ruby-progressbar (~> 1.7) 86 | unicode-display_width (>= 2.4.0, < 4.0) 87 | rubocop-ast (1.45.1) 88 | parser (>= 3.3.7.2) 89 | prism (~> 1.4) 90 | rubocop-performance (1.25.0) 91 | lint_roller (~> 1.1) 92 | rubocop (>= 1.75.0, < 2.0) 93 | rubocop-ast (>= 1.38.0, < 2.0) 94 | ruby-progressbar (1.13.0) 95 | standard (1.50.0) 96 | language_server-protocol (~> 3.17.0.2) 97 | lint_roller (~> 1.0) 98 | rubocop (~> 1.75.5) 99 | standard-custom (~> 1.0.0) 100 | standard-performance (~> 1.8) 101 | standard-custom (1.0.2) 102 | lint_roller (~> 1.0) 103 | rubocop (~> 1.50) 104 | standard-performance (1.8.0) 105 | lint_roller (~> 1.1) 106 | rubocop-performance (~> 1.25.0) 107 | unicode-display_width (3.1.4) 108 | unicode-emoji (~> 4.0, >= 4.0.4) 109 | unicode-emoji (4.0.4) 110 | zeitwerk (2.7.3) 111 | 112 | PLATFORMS 113 | arm64-darwin-24 114 | ruby 115 | 116 | DEPENDENCIES 117 | rails-mcp-server! 118 | standard 119 | 120 | BUNDLED WITH 121 | 2.6.3 122 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 [Mario Alberto Chávez Cárdenas] 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Rails MCP Server 2 | 3 | A Ruby implementation of a Model Context Protocol (MCP) server for Rails projects. This server allows LLMs (Large Language Models) to interact with Rails projects through the Model Context Protocol, providing capabilities for code analysis, exploration, and development assistance. 4 | 5 | ## What is MCP? 6 | 7 | The Model Context Protocol (MCP) is a standardized way for AI models to interact with their environment. It defines a structured method for models to request and use tools, access resources, and maintain context during interactions. 8 | 9 | This Rails MCP Server implements the MCP specification to give AI models access to Rails projects for code analysis, exploration, and assistance. 10 | 11 | ## Features 12 | 13 | - Manage multiple Rails projects 14 | - Browse project files and structures 15 | - View Rails routes 16 | - Inspect model information and relationships 17 | - Get database schema information 18 | - Analyze controller-view relationships 19 | - Analyze environment configurations 20 | - Access comprehensive Rails, Turbo, Stimulus, and Kamal documentation 21 | - Follow the Model Context Protocol standard 22 | - Seamless integration with LLM clients 23 | 24 | ## Installation 25 | 26 | Install the gem: 27 | 28 | ```bash 29 | gem install rails-mcp-server 30 | ``` 31 | 32 | After installation, the `rails-mcp-server` and `rails-mcp-setup-claude` executables will be available in your PATH. 33 | 34 | ## Configuration 35 | 36 | The Rails MCP Server follows the XDG Base Directory Specification for configuration files: 37 | 38 | - On macOS: `$XDG_CONFIG_HOME/rails-mcp` or `~/.config/rails-mcp` if XDG_CONFIG_HOME is not set 39 | - On Windows: `%APPDATA%\rails-mcp` 40 | 41 | The server will automatically create these directories and an empty `projects.yml` file the first time it runs. 42 | 43 | To configure your projects: 44 | 45 | 1. Edit the `projects.yml` file in your config directory to include your Rails projects: 46 | 47 | ```yaml 48 | store: "~/projects/store" 49 | blog: "~/projects/rails-blog" 50 | ecommerce: "/full/path/to/ecommerce-app" 51 | ``` 52 | 53 | Each key in the YAML file is a project name (which will be used with the `switch_project` tool), and each value is the path to the project directory. 54 | 55 | ## Usage 56 | 57 | ### Starting the server 58 | 59 | The Rails MCP Server can run in two modes: 60 | 61 | 1. **STDIO mode (default)**: Communicates over standard input/output for direct integration with clients like Claude Desktop. 62 | 2. **HTTP mode**: Runs as an HTTP server with JSON-RPC and Server-Sent Events (SSE) endpoints. 63 | 64 | ```bash 65 | # Start in default STDIO mode 66 | rails-mcp-server 67 | 68 | # Start in HTTP mode on the default port (6029) 69 | rails-mcp-server --mode http 70 | 71 | # Start in HTTP mode on a custom port 72 | rails-mcp-server --mode http -p 8080 73 | ``` 74 | 75 | When running in HTTP mode, the server provides two endpoints: 76 | 77 | - JSON-RPC endpoint: `http://localhost:/mcp/messages` 78 | - SSE endpoint: `http://localhost:/mcp/sse` 79 | 80 | ### Logging Options 81 | 82 | The server logs to a file in the `./log` directory by default. You can customize logging with these options: 83 | 84 | ```bash 85 | # Set the log level (debug, info, error) 86 | rails-mcp-server --log-level debug 87 | ``` 88 | 89 | ## Claude Desktop Integration 90 | 91 | The Rails MCP Server can be used with Claude Desktop. There are two options to set this up: 92 | 93 | ### Option 1: Use the setup script (recommended) 94 | 95 | Run the setup script which will automatically configure Claude Desktop and set up the proper XDG-compliant directory structure: 96 | 97 | ```bash 98 | rails-mcp-setup-claude 99 | ``` 100 | 101 | The script will: 102 | 103 | - Create the appropriate config directory for your platform 104 | - Create an empty `projects.yml` file if it doesn't exist 105 | - Update the Claude Desktop configuration 106 | 107 | After running the script, restart Claude Desktop to apply the changes. 108 | 109 | ### Option 2: Direct Configuration 110 | 111 | 1. Create the appropriate config directory for your platform: 112 | - macOS: `$XDG_CONFIG_HOME/rails-mcp` or `~/.config/rails-mcp` if XDG_CONFIG_HOME is not set 113 | - Windows: `%APPDATA%\rails-mcp` 114 | 115 | 2. Create a `projects.yml` file in that directory with your Rails projects. 116 | 117 | 3. Find or create the Claude Desktop configuration file: 118 | - macOS: `~/Library/Application Support/Claude/claude_desktop_config.json` 119 | - Windows: `%APPDATA%\Claude\claude_desktop_config.json` 120 | 121 | 4. Add or update the MCP server configuration: 122 | 123 | ```json 124 | { 125 | "mcpServers": { 126 | "railsMcpServer": { 127 | "command": "ruby", 128 | "args": ["/full/path/to/rails-mcp-server/exe/rails-mcp-server"] 129 | } 130 | } 131 | } 132 | ``` 133 | 134 | 5. Restart Claude Desktop to apply the changes. 135 | 136 | ### Ruby Version Manager Users 137 | 138 | Claude Desktop launches the MCP server using your system's default Ruby environment, bypassing version manager initialization (e.g., rbenv, RVM). The MCP server needs to use the same Ruby version where it was installed, as MCP server startup failures can occur when using an incompatible Ruby version. 139 | 140 | If you are using a Ruby version manager such as rbenv, you can create a symbolic link to your Ruby shim to ensure the correct version is used: 141 | 142 | ```bash 143 | sudo ln -s /home/your_user/.rbenv/shims/ruby /usr/local/bin/ruby 144 | ``` 145 | 146 | Replace "/home/your_user/.rbenv/shims/ruby" with your actual path for the Ruby shim. 147 | 148 | ### Using an MCP Proxy (Advanced) 149 | 150 | Claude Desktop and many other LLM clients only support STDIO mode communication, but you might want to use the HTTP/SSE capabilities of the server. An MCP proxy can bridge this gap: 151 | 152 | 1. Start the Rails MCP Server in HTTP mode: 153 | 154 | ```bash 155 | rails-mcp-server --mode http 156 | ``` 157 | 158 | 2. Install and run an MCP proxy. There are several implementations available in different languages. An MCP proxy allows a client that only supports STDIO communication to communicate via HTTP SSE. Here's an example using a JavaScript-based MCP proxy: 159 | 160 | ```bash 161 | # Install the Node.js based MCP proxy 162 | npm install -g mcp-remote 163 | 164 | # Run the proxy, pointing to your running Rails MCP Server 165 | npx mcp-remote http://localhost:6029/mcp/sse 166 | ``` 167 | 168 | 3. Configure Claude Desktop (or other LLM client) to use the proxy instead of connecting directly to the server: 169 | 170 | ```json 171 | { 172 | "mcpServers": { 173 | "railsMcpServer": { 174 | "command": "npx", 175 | "args": ["mcp-remote", "http://localhost:6029/mcp/sse"] 176 | } 177 | } 178 | } 179 | ``` 180 | 181 | This setup allows STDIO-only clients to communicate with the Rails MCP Server through the proxy, benefiting from the HTTP/SSE capabilities while maintaining client compatibility. 182 | 183 | ## How the Server Works 184 | 185 | The Rails MCP Server implements the Model Context Protocol using either: 186 | 187 | - **STDIO mode**: Reads JSON-RPC 2.0 requests from standard input and returns responses to standard output. 188 | - **HTTP mode**: Provides HTTP endpoints for JSON-RPC 2.0 requests and Server-Sent Events. 189 | 190 | Each request includes a sequence number to match requests with responses, as defined in the MCP specification. The server maintains project context and provides Rails-specific analysis capabilities across multiple codebases. 191 | 192 | ## Available Tools 193 | 194 | The server provides the following tools for interacting with Rails projects: 195 | 196 | ### 1. `switch_project` 197 | 198 | **Description:** Change the active Rails project to interact with a different codebase. Must be called before using other tools. Available projects are defined in the projects.yml configuration file. 199 | 200 | **Parameters:** 201 | 202 | - `project_name`: (String, required) Name of the project as defined in the projects.yml file (case-sensitive) 203 | 204 | #### Examples 205 | 206 | ``` 207 | Can you switch to the "store" project so we can explore it? 208 | ``` 209 | 210 | ``` 211 | I'd like to analyze my "blog" application. Please switch to that project first. 212 | ``` 213 | 214 | ``` 215 | Switch to the "ecommerce" project and give me a summary of the codebase. 216 | ``` 217 | 218 | ### 2. `project_info` 219 | 220 | **Description:** Retrieve comprehensive information about the current Rails project, including Rails version, directory structure, API-only status, and overall project organization. Useful for initial project exploration and understanding the codebase structure. 221 | 222 | **Parameters:** None 223 | 224 | #### Examples 225 | 226 | ``` 227 | Now that we're in the blog project, can you give me an overview of the project structure and Rails version? 228 | ``` 229 | 230 | ``` 231 | Tell me about this Rails application. What version is it running and how is it organized? 232 | ``` 233 | 234 | ``` 235 | I'd like to understand the high-level architecture of this project. Can you provide the project information? 236 | ``` 237 | 238 | ### 3. `list_files` 239 | 240 | **Description:** List files in the Rails project matching specific criteria. Use this to explore project directories or locate specific file types. If no parameters are provided, lists files in the project root. 241 | 242 | **Parameters:** 243 | 244 | - `directory`: (String, optional) Directory path relative to the project root (e.g., 'app/models', 'config'). Leave empty to list files at the root. 245 | - `pattern`: (String, optional) File pattern using glob syntax (e.g., '*.rb' for Ruby files, '*.erb' for ERB templates, '*_controller.rb' for controllers) 246 | 247 | #### Examples 248 | 249 | ``` 250 | Can you list all the model files in this project? 251 | ``` 252 | 253 | ``` 254 | Show me all the controller files in the app/controllers directory. 255 | ``` 256 | 257 | ``` 258 | I need to see all the view templates in the users section. Can you list the files in app/views/users? 259 | ``` 260 | 261 | ``` 262 | List all the JavaScript files in the app/javascript directory. 263 | ``` 264 | 265 | ### 4. `get_file` 266 | 267 | **Description:** Retrieve the complete content of a specific file with syntax highlighting. Use this to examine implementation details, configurations, or any text file in the project. 268 | 269 | **Parameters:** 270 | 271 | - `path`: (String, required) File path relative to the project root (e.g., 'app/models/user.rb', 'config/routes.rb'). Use list_files first if you're not sure about the exact path. 272 | 273 | #### Examples 274 | 275 | ``` 276 | Can you show me the content of the User model file? 277 | ``` 278 | 279 | ``` 280 | I need to see what's in app/controllers/products_controller.rb. Can you retrieve that file? 281 | ``` 282 | 283 | ``` 284 | Please show me the application.rb file so I can check the configuration settings. 285 | ``` 286 | 287 | ``` 288 | I'd like to examine the routes file. Can you display the content of config/routes.rb? 289 | ``` 290 | 291 | ### 5. `get_routes` 292 | 293 | **Description:** Retrieve all HTTP routes defined in the Rails application with their associated controllers and actions. Equivalent to running 'rails routes' command. This helps understand the API endpoints or page URLs available in the application. 294 | 295 | **Parameters:** None 296 | 297 | #### Examples 298 | 299 | ``` 300 | Can you show me all the routes defined in this application? 301 | ``` 302 | 303 | ``` 304 | I need to understand the API endpoints available in this project. Can you list the routes? 305 | ``` 306 | 307 | ``` 308 | Show me the routing configuration for this Rails app so I can see how the URLs are structured. 309 | ``` 310 | 311 | ### 6. `analyze_models` 312 | 313 | **Description:** Retrieve detailed information about Active Record models in the project. When called without parameters, lists all model files. When a specific model is specified, returns its schema, associations (has_many, belongs_to, has_one), and complete source code. 314 | 315 | **Parameters:** 316 | 317 | - `model_name`: (String, optional) Class name of a specific model to get detailed information for (e.g., 'User', 'Product'). Use CamelCase, not snake_case. If omitted, returns a list of all models. 318 | 319 | #### Examples 320 | 321 | ``` 322 | Can you list all the models in this Rails project? 323 | ``` 324 | 325 | ``` 326 | I'd like to understand the User model in detail. Can you show me its schema, associations, and code? 327 | ``` 328 | 329 | ``` 330 | Show me the Product model's definition, including its relationships with other models. 331 | ``` 332 | 333 | ``` 334 | What are all the models in this application, and can you then show me details for the Order model specifically? 335 | ``` 336 | 337 | ### 7. `get_schema` 338 | 339 | **Description:** Retrieve database schema information for the Rails application. Without parameters, returns all tables and the complete schema.rb. With a table name, returns detailed column information including data types, constraints, and foreign keys for that specific table. 340 | 341 | **Parameters:** 342 | 343 | - `table_name`: (String, optional) Database table name to get detailed schema information for (e.g., 'users', 'products'). Use snake_case, plural form. If omitted, returns complete database schema. 344 | 345 | #### Examples 346 | 347 | ``` 348 | Can you show me the complete database schema for this Rails application? 349 | ``` 350 | 351 | ``` 352 | I'd like to see the structure of the users table. Can you retrieve that schema information? 353 | ``` 354 | 355 | ``` 356 | Show me the columns and their data types in the products table. 357 | ``` 358 | 359 | ``` 360 | I need to understand the database design. Can you first list all tables and then show me details for the orders table? 361 | ``` 362 | 363 | ### 8. `analyze_controller_views` 364 | 365 | **Description:** Analyze the relationships between controllers, their actions, and corresponding views to understand the application's UI flow. 366 | 367 | **Parameters:** 368 | 369 | - `controller_name`: (String, optional) Name of a specific controller to analyze (e.g., 'UsersController' or 'users'). If omitted, all controllers will be analyzed. 370 | 371 | #### Examples 372 | 373 | ``` 374 | Can you analyze the Users controller and its views to help me understand the UI flow? 375 | ``` 376 | 377 | ``` 378 | Show me how the ProductsController connects to its views and what actions are available. 379 | ``` 380 | 381 | ``` 382 | I want to understand the entire controller-view structure of this application. 383 | ``` 384 | 385 | ### 9. `analyze_environment_config` 386 | 387 | **Description:** Analyze environment configurations to identify inconsistencies, security issues, and missing variables across environments. 388 | 389 | **Parameters:** None 390 | 391 | #### Examples 392 | 393 | ``` 394 | Can you analyze the environment configurations to find any security issues or missing environment variables? 395 | ``` 396 | 397 | ``` 398 | Check the configuration files for any inconsistencies between development and production environments. 399 | ``` 400 | 401 | ### 10. `load_guide` 402 | 403 | **Description:** Load documentation guides from Rails, Turbo, Stimulus, Kamal, or Custom. Use this to get guide content for context in conversations. 404 | 405 | **Parameters:** 406 | 407 | - `guides`: (String, required) The guides library to search: 'rails', 'turbo', 'stimulus', 'kamal', or 'custom' 408 | - `guide`: (String, optional) Specific guide name to load. If not provided, returns available guides list. 409 | 410 | #### Examples 411 | 412 | ``` 413 | Can you load the Rails getting started guide? 414 | ``` 415 | 416 | ``` 417 | Show me the available Turbo guides and then load the one about Turbo Frames. 418 | ``` 419 | 420 | ``` 421 | I need help with Stimulus. Can you load the hello_stimulus guide? 422 | ``` 423 | 424 | ``` 425 | Load the Kamal installation guide so I can understand deployment options. 426 | ``` 427 | 428 | ## Resources and Documentation 429 | 430 | The Rails MCP Server provides access to comprehensive documentation through both the `load_guide` tool and direct MCP resource access. You can access official guides for Rails, Turbo, Stimulus, and Kamal, as well as import your own custom documentation. 431 | 432 | ### Available Resource Categories 433 | 434 | - **Rails Guides**: Official Ruby on Rails 8.0.2 documentation 435 | - **Turbo Guides**: Official Turbo (Hotwire) framework documentation 436 | - **Stimulus Guides**: Official Stimulus JavaScript framework documentation 437 | - **Kamal Guides**: Official Kamal deployment tool documentation 438 | - **Custom Guides**: Your imported markdown files 439 | 440 | ### Getting Started with Resources 441 | 442 | Before using resources, you need to download them: 443 | 444 | ```bash 445 | # Download Rails guides 446 | rails-mcp-server-download-resources rails 447 | 448 | # Download Turbo guides 449 | rails-mcp-server-download-resources turbo 450 | 451 | # Import custom markdown files 452 | rails-mcp-server-download-resources --file /path/to/your/docs/ 453 | ``` 454 | 455 | ### Resource Access Methods 456 | 457 | 1. **Tool-based access**: Use the `load_guide` tool in conversations 458 | 2. **Direct resource access**: MCP clients can query resources using URI patterns like `rails://guides/{guide_name}` 459 | 460 | For complete information about downloading, managing, and using resources, see the [Resources Guide](docs/RESOURCES.md). 461 | 462 | ## Testing and Debugging 463 | 464 | The easiest way to test and debug the Rails MCP Server is by using the MCP Inspector, a developer tool designed specifically for testing and debugging MCP servers. 465 | 466 | To use MCP Inspector with Rails MCP Server: 467 | 468 | ```bash 469 | # Install and run MCP Inspector with your Rails MCP Server 470 | npm -g install @modelcontextprotocol/inspector 471 | 472 | npx @modelcontextprotocol/inspector /path/to/rails-mcp-server 473 | ``` 474 | 475 | This will: 476 | 477 | 1. Start your Rails MCP Server in HTTP mode 478 | 2. Launch the MCP Inspector UI in your browser (default port: 6274) 479 | 3. Set up an MCP Proxy server (default port: 6277) 480 | 481 | In the MCP Inspector UI, you can: 482 | 483 | - See all available tools 484 | - Execute tool calls interactively 485 | - View request and response details 486 | - Debug issues in real-time 487 | 488 | The Inspector UI provides an intuitive interface to interact with your MCP server, making it easy to test and debug your Rails MCP Server implementation. 489 | 490 | ## Integration with LLM Clients 491 | 492 | This server is designed to be integrated with LLM clients that support the Model Context Protocol, such as Claude Desktop or other MCP-compatible applications. 493 | 494 | To use with an MCP client: 495 | 496 | 1. Start the Rails MCP Server (it will use STDIO mode by default) 497 | 2. Connect your MCP-compatible client to the server 498 | 3. The client will be able to use the available tools to interact with your Rails projects 499 | 500 | ## License 501 | 502 | This Rails MCP server is released under the MIT License, a permissive open-source license that allows for free use, modification, distribution, and private use. 503 | 504 | Copyright (c) 2025 Mario Alberto Chávez Cárdenas 505 | 506 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 507 | 508 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 509 | 510 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 511 | 512 | ## Contributing 513 | 514 | Bug reports and pull requests are welcome on GitHub at . 515 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rake/testtask" 3 | 4 | Rake::TestTask.new(:test) do |t| 5 | t.libs << "test" 6 | t.libs << "lib" 7 | t.test_files = FileList["test/**/*_test.rb"] 8 | end 9 | 10 | task default: :test 11 | -------------------------------------------------------------------------------- /config/resources.yml: -------------------------------------------------------------------------------- 1 | rails: 2 | base_url: "https://raw.githubusercontent.com/rails/rails/v8.0.2/guides/source" 3 | description: "Official Ruby on Rails documentation guides" 4 | version: "8.0.2" 5 | files: 6 | # Getting Started 7 | - getting_started.md 8 | - install_ruby_on_rails.md 9 | - getting_started_with_devcontainer.md 10 | 11 | # Core Components 12 | - active_record_basics.md 13 | - active_record_migrations.md 14 | - active_record_validations.md 15 | - active_record_callbacks.md 16 | - active_record_querying.md 17 | - active_record_encryption.md 18 | - active_record_multiple_databases.md 19 | - active_record_postgresql.md 20 | - active_record_composite_primary_keys.md 21 | - association_basics.md 22 | 23 | # Action Pack 24 | - action_controller_overview.md 25 | - action_controller_advanced_topics.md 26 | - action_view_overview.md 27 | - action_view_helpers.md 28 | - layouts_and_rendering.md 29 | - form_helpers.md 30 | - routing.md 31 | 32 | # Other Action Components 33 | - action_mailer_basics.md 34 | - action_cable_overview.md 35 | - action_text_overview.md 36 | - action_mailbox_basics.md 37 | - active_job_basics.md 38 | - active_storage_overview.md 39 | 40 | # Active Support 41 | - active_support_core_extensions.md 42 | - active_support_instrumentation.md 43 | - active_model_basics.md 44 | 45 | # Configuration and Setup 46 | - configuring.md 47 | - command_line.md 48 | - asset_pipeline.md 49 | - working_with_javascript_in_rails.md 50 | - autoloading_and_reloading_constants.md 51 | - initialization.md 52 | - rails_on_rack.md 53 | 54 | # Advanced Topics 55 | - engines.md 56 | - threading_and_code_execution.md 57 | - api_app.md 58 | - generators.md 59 | - plugins.md 60 | - caching_with_rails.md 61 | 62 | # Development and Testing 63 | - testing.md 64 | - debugging_rails_applications.md 65 | - development_dependencies_install.md 66 | - error_reporting.md 67 | 68 | # Deployment and Performance 69 | - tuning_performance_for_deployment.md 70 | - upgrading_ruby_on_rails.md 71 | 72 | # Security and Internationalization 73 | - security.md 74 | - i18n.md 75 | 76 | # Contributing 77 | - contributing_to_ruby_on_rails.md 78 | - api_documentation_guidelines.md 79 | - ruby_on_rails_guides_guidelines.md 80 | - maintenance_policy.md 81 | 82 | # Stimulus JavaScript Framework Documentation 83 | stimulus: 84 | base_url: "https://raw.githubusercontent.com/hotwired/stimulus/refs/heads/main/docs" 85 | description: "Stimulus JavaScript framework documentation" 86 | version: "latest" 87 | files: 88 | # Handbook - Main Documentation 89 | - handbook/00_the_origin_of_stimulus.md 90 | - handbook/01_introduction.md 91 | - handbook/02_hello_stimulus.md 92 | - handbook/03_building_something_real.md 93 | - handbook/04_designing_for_resilience.md 94 | - handbook/05_managing_state.md 95 | - handbook/06_working_with_external_resources.md 96 | - handbook/07_installing_stimulus.md 97 | 98 | # Reference - API Documentation 99 | - reference/actions.md 100 | - reference/controllers.md 101 | - reference/css_classes.md 102 | - reference/lifecycle_callbacks.md 103 | - reference/outlets.md 104 | - reference/targets.md 105 | - reference/using_typescript.md 106 | - reference/values.md 107 | 108 | # Turbo Hotwire Framework Documentation 109 | turbo: 110 | base_url: "https://raw.githubusercontent.com/hotwired/turbo-site/refs/heads/main/_source" 111 | description: "Turbo Hotwire framework documentation" 112 | version: "latest" 113 | files: 114 | # Handbook - Main Documentation 115 | - handbook/01_introduction.md 116 | - handbook/02_drive.md 117 | - handbook/03_page_refreshes.md 118 | - handbook/04_frames.md 119 | - handbook/05_streams.md 120 | - handbook/06_native.md 121 | - handbook/07_building.md 122 | - handbook/08_installing.md 123 | 124 | # Reference - API Documentation 125 | - reference/attributes.md 126 | - reference/drive.md 127 | - reference/events.md 128 | - reference/frames.md 129 | - reference/streams.md 130 | 131 | # Kamal Deploy Documentation 132 | kamal: 133 | base_url: "https://raw.githubusercontent.com/basecamp/kamal-site/refs/heads/main/docs" 134 | description: "Kamal deployment tool documentation" 135 | version: "latest" 136 | files: 137 | # Installation 138 | - installation/index.md 139 | - installation/dockerized.md 140 | 141 | # Configuration 142 | - configuration/accessories.md 143 | - configuration/aliases.md 144 | - configuration/anchors.md 145 | - configuration/booting.md 146 | - configuration/builder-examples.md 147 | - configuration/builders.md 148 | - configuration/cron.md 149 | - configuration/docker-registry.md 150 | - configuration/environment-variables.md 151 | - configuration/logging.md 152 | - configuration/overview.md 153 | - configuration/proxy.md 154 | - configuration/roles.md 155 | - configuration/servers.md 156 | - configuration/ssh.md 157 | - configuration/sshkit.md 158 | 159 | # Commands 160 | - commands/accessory.md 161 | - commands/app.md 162 | - commands/audit.md 163 | - commands/build.md 164 | - commands/config.md 165 | - commands/deploy.md 166 | - commands/details.md 167 | - commands/docs.md 168 | - commands/help.md 169 | - commands/init.md 170 | - commands/lock.md 171 | - commands/proxy.md 172 | - commands/prune.md 173 | - commands/redeploy.md 174 | - commands/registry.md 175 | - commands/remove.md 176 | - commands/rollback.md 177 | - commands/running-commands-on-servers.md 178 | - commands/secrets.md 179 | - commands/server.md 180 | - commands/setup.md 181 | - commands/upgrade.md 182 | - commands/version.md 183 | - commands/view-all-commands.md 184 | 185 | # Hooks 186 | - hooks/overview.md 187 | - hooks/docker-setup.md 188 | - hooks/post-app-boot.md 189 | - hooks/post-deploy.md 190 | - hooks/post-proxy-reboot.md 191 | - hooks/pre-app-boot.md 192 | - hooks/pre-build.md 193 | - hooks/pre-connect.md 194 | - hooks/pre-deploy.md 195 | - hooks/pre-proxy-reboot.md 196 | 197 | # Upgrading 198 | - upgrading/configuration-changes.md 199 | - upgrading/continuing-to-use-traefik.md 200 | - upgrading/network-changes.md 201 | - upgrading/overview.md 202 | - upgrading/proxy-changes.md 203 | - upgrading/secrets-changes.md 204 | -------------------------------------------------------------------------------- /docs/RESOURCES.md: -------------------------------------------------------------------------------- 1 | # Rails MCP Server - Resources Guide 2 | 3 | This guide explains how the Rails MCP Server handles documentation resources, how to download predefined guides, and how to load custom documentation files. Resources in the Rails MCP Server provide access to comprehensive documentation for Rails and related frameworks through both the `load_guide` tool and direct MCP resource access. 4 | 5 | ## Table of Contents 6 | 7 | - [What are Resources?](#what-are-resources) 8 | - [Available Resource Categories](#available-resource-categories) 9 | - [Downloading Predefined Resources](#downloading-predefined-resources) 10 | - [Loading Custom Files](#loading-custom-files) 11 | - [Using the load_guide Tool](#using-the-load_guide-tool) 12 | - [Direct Resource Access](#direct-resource-access) 13 | - [Troubleshooting](#troubleshooting) 14 | 15 | ## What are Resources? 16 | 17 | Resources in the Rails MCP Server are documentation guides that can be accessed through two main methods: 18 | 19 | 1. **Tool-based access**: Using the `load_guide` tool to retrieve specific guides 20 | 2. **Direct resource access**: MCP clients can directly query resources using URI templates 21 | 22 | Resources are stored locally in your configuration directory and can be: 23 | - **Predefined resources**: Official documentation from Rails, Turbo, Stimulus, and Kamal 24 | - **Custom resources**: Your own markdown files imported into the system 25 | 26 | ## Available Resource Categories 27 | 28 | The Rails MCP Server supports five categories of resources: 29 | 30 | ### 1. Rails Guides 31 | - **Framework**: Ruby on Rails 32 | - **Content**: Official Rails 8.0.2 documentation 33 | 34 | ### 2. Turbo Guides 35 | - **Framework**: Turbo (Hotwire) 36 | - **Content**: Official Turbo framework documentation 37 | 38 | ### 3. Stimulus Guides 39 | - **Framework**: Stimulus (Hotwire) 40 | - **Content**: Official Stimulus JavaScript framework documentation 41 | 42 | ### 4. Kamal Guides 43 | - **Framework**: Kamal Deploy 44 | - **Content**: Official Kamal deployment tool documentation 45 | 46 | ### 5. Custom Guides 47 | - **Framework**: Custom/Local 48 | - **Content**: Your imported markdown files 49 | 50 | ## Downloading Predefined Resources 51 | 52 | Before you can use predefined resources, you need to download them using the resource downloader tool. 53 | 54 | ### Basic Download Command 55 | 56 | ```bash 57 | # Download Rails guides 58 | rails-mcp-server-download-resources rails 59 | 60 | # Download Turbo guides 61 | rails-mcp-server-download-resources turbo 62 | 63 | # Download Stimulus guides 64 | rails-mcp-server-download-resources stimulus 65 | 66 | # Download Kamal guides 67 | rails-mcp-server-download-resources kamal 68 | ``` 69 | 70 | ### Download Options 71 | 72 | ```bash 73 | # Force download even if files haven't changed 74 | rails-mcp-server-download-resources --force rails 75 | 76 | # Verbose output to see download progress 77 | rails-mcp-server-download-resources --verbose turbo 78 | 79 | # Combine options 80 | rails-mcp-server-download-resources --verbose --force stimulus 81 | ``` 82 | 83 | ### Download Process 84 | 85 | When you run the download command: 86 | 87 | 1. **Creates directories**: Sets up the resource folder structure in your config directory 88 | 2. **Downloads guides**: Fetches the latest documentation from official repositories 89 | 3. **Creates manifest**: Generates a manifest.yaml file tracking all downloaded files 90 | 4. **Tracks changes**: Monitors file modifications to avoid unnecessary re-downloads 91 | 92 | ### Resource Storage Location 93 | 94 | Downloaded resources are stored in: 95 | - **macOS**: `~/.config/rails-mcp/resources/` 96 | - **Windows**: `%APPDATA%\rails-mcp\resources\` 97 | 98 | Directory structure: 99 | ``` 100 | ~/.config/rails-mcp/resources/ 101 | ├── rails/ 102 | │ ├── manifest.yaml 103 | │ ├── getting_started.md 104 | │ ├── active_record_basics.md 105 | │ └── ... 106 | ├── turbo/ 107 | │ ├── manifest.yaml 108 | │ ├── handbook/ 109 | │ │ ├── 01_introduction.md 110 | │ │ └── ... 111 | │ └── reference/ 112 | │ ├── streams.md 113 | │ └── ... 114 | ├── stimulus/ 115 | ├── kamal/ 116 | └── custom/ 117 | ``` 118 | 119 | ## Loading Custom Files 120 | 121 | You can import your own markdown files into the Custom guides category using the `--file` option. 122 | 123 | ### Import Single File 124 | 125 | ```bash 126 | # Import a single markdown file 127 | rails-mcp-server-download-resources --file /path/to/guide.md 128 | 129 | # Force import even if file hasn't changed 130 | rails-mcp-server-download-resources --force --file /path/to/api-docs.md 131 | ``` 132 | 133 | ### Import Directory 134 | 135 | ```bash 136 | # Import all markdown files from a directory 137 | rails-mcp-server-download-resources --file /path/to/docs/ 138 | 139 | # Verbose import with progress information 140 | rails-mcp-server-download-resources --verbose --file /path/to/project-docs/ 141 | ``` 142 | 143 | ### Import Process 144 | 145 | When importing custom files: 146 | 147 | 1. **File validation**: Checks that source files exist and are readable 148 | 2. **Filename normalization**: Converts filenames to lowercase with underscores 149 | 3. **Duplication handling**: Skips unchanged files unless `--force` is used 150 | 4. **Manifest updates**: Tracks imported files in the custom manifest 151 | 152 | ### Filename Normalization 153 | 154 | Custom files are normalized for consistency: 155 | - `API Documentation.md` → `api_documentation.md` 156 | - `Setup-Guide.md` → `setup_guide.md` 157 | - `user_manual.md` → `user_manual.md` (already normalized) 158 | 159 | ## Using the load_guide Tool 160 | 161 | The `load_guide` tool is the primary way to access resources programmatically within MCP conversations. 162 | 163 | ### Basic Syntax 164 | 165 | ``` 166 | load_guide guides: "category" guide: "guide_name" 167 | ``` 168 | 169 | ### Loading Specific Guides 170 | 171 | #### Rails Guides 172 | ``` 173 | Can you load the Rails getting started guide? 174 | 175 | I need to see the Active Record basics documentation. 176 | 177 | Show me the Rails routing guide. 178 | ``` 179 | 180 | #### Turbo Guides 181 | ``` 182 | Can you load the Turbo introduction guide? 183 | 184 | I'd like to see the Turbo Frames documentation. 185 | 186 | Show me the Turbo streams reference. 187 | ``` 188 | 189 | #### Stimulus Guides 190 | ``` 191 | Load the Hello Stimulus tutorial for me. 192 | 193 | I need help with Stimulus controllers - can you show me that guide? 194 | 195 | Can you display the Stimulus targets reference? 196 | ``` 197 | 198 | #### Kamal Guides 199 | ``` 200 | Show me the Kamal installation guide. 201 | 202 | I need to understand Kamal deployment - can you load that documentation? 203 | 204 | Can you get the Kamal configuration overview? 205 | ``` 206 | 207 | #### Custom Guides 208 | ``` 209 | Load my API documentation guide. 210 | 211 | Can you show me the setup guide I imported? 212 | 213 | I need to see my custom user manual. 214 | ``` 215 | 216 | ### Listing Available Guides 217 | 218 | ``` 219 | What Rails guides are available? 220 | 221 | Show me all the Turbo documentation. 222 | 223 | List the available custom guides I've imported. 224 | ``` 225 | 226 | ### Tool Output Format 227 | 228 | The `load_guide` tool returns formatted content with: 229 | 230 | ```markdown 231 | # Guide Title 232 | 233 | **Source:** Framework Name 234 | **Guide:** guide_name 235 | **File:** path/to/file.md (for sectioned guides) 236 | 237 | --- 238 | 239 | [Guide content here...] 240 | ``` 241 | 242 | ## Direct Resource Access 243 | 244 | MCP clients that support resources can access documentation directly without using the `load_guide` tool. 245 | 246 | ### Resource URIs 247 | 248 | Each resource category has specific URI patterns: 249 | 250 | #### List Resources (Available Guides) 251 | - `rails://guides` - List all Rails guides 252 | - `turbo://guides` - List all Turbo guides 253 | - `stimulus://guides` - List all Stimulus guides 254 | - `kamal://guides` - List all Kamal guides 255 | - `custom://guides` - List all custom guides 256 | 257 | #### Specific Guide Resources 258 | - `rails://guides/{guide_name}` - Access specific Rails guide 259 | - `turbo://guides/{guide_name}` - Access specific Turbo guide 260 | - `stimulus://guides/{guide_name}` - Access specific Stimulus guide 261 | - `kamal://guides/{guide_name}` - Access specific Kamal guide 262 | - `custom://guides/{guide_name}` - Access specific custom guide 263 | 264 | ### Resource Metadata 265 | 266 | Each resource includes metadata: 267 | - `resource_name`: Human-readable name 268 | - `description`: Resource description 269 | - `mime_type`: Content type (text/markdown) 270 | - `uri`: URI template pattern 271 | 272 | ## Troubleshooting 273 | 274 | ### Common Issues 275 | 276 | #### Resource Not Found 277 | ``` 278 | Error: Unknown resource: rails 279 | ``` 280 | **Solution**: Check available resources with `--help` flag 281 | 282 | #### Download Failures 283 | ``` 284 | failed (HTTP 404) 285 | ``` 286 | **Solution**: Resource may have moved; try updating or check internet connection 287 | 288 | #### Permission Errors 289 | ``` 290 | Error: Source not readable: /path/to/file 291 | ``` 292 | **Solution**: Check file permissions and path existence 293 | 294 | #### Guide Not Found 295 | ``` 296 | Guide 'invalid_guide' not found in Rails guides. 297 | ``` 298 | **Solution**: Use `load_guide guides: "rails"` to see available guides 299 | 300 | ### Verbose Troubleshooting 301 | 302 | Use verbose mode for detailed information: 303 | 304 | ```bash 305 | # Verbose download 306 | rails-mcp-server-download-resources --verbose rails 307 | 308 | # Verbose import 309 | rails-mcp-server-download-resources --verbose --file /path/to/docs/ 310 | ``` 311 | 312 | ### Manual Resource Management 313 | 314 | If needed, you can manually manage resources: 315 | 316 | ```bash 317 | # Remove all downloaded resources 318 | rm -rf ~/.config/rails-mcp/resources/ 319 | 320 | # Re-download everything 321 | rails-mcp-server-download-resources rails 322 | rails-mcp-server-download-resources turbo 323 | rails-mcp-server-download-resources stimulus 324 | rails-mcp-server-download-resources kamal 325 | ``` 326 | 327 | ## Best Practices 328 | 329 | ### Resource Organization 330 | 1. **Download first**: Always download resources before using the `load_guide` tool 331 | 2. **Update regularly**: Keep resources current by re-downloading periodically 332 | 3. **Organize custom guides**: Use descriptive filenames for custom imports 333 | 334 | ### Performance Tips 335 | 1. **Selective downloads**: Only download resources you need 336 | 2. **Avoid force**: Don't use `--force` unless necessary 337 | 3. **Batch imports**: Import multiple custom files at once using directory paths 338 | 339 | This comprehensive resource system makes the Rails MCP Server a powerful documentation companion for Rails development, providing instant access to official guides and your custom documentation through both tool-based and direct resource access methods. 340 | -------------------------------------------------------------------------------- /exe/rails-mcp-server: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | if defined?(Bundler) && !defined?(Bundler::ORIGINAL_ENV) 4 | require "bundler/setup" 5 | Bundler::ORIGINAL_ENV = ENV.to_h 6 | end 7 | 8 | require "fast_mcp" 9 | require "rack" 10 | require "rack/handler/puma" 11 | require "puma" 12 | require "puma/configuration" 13 | require "puma/launcher" 14 | require_relative "../lib/rails_mcp_server" 15 | 16 | if ARGV[0] == "version" 17 | puts "Rails MCP Server version #{RailsMcpServer::VERSION}" 18 | exit 0 19 | end 20 | 21 | # Help message 22 | if ARGV[0] == "--help" || ARGV[0] == "-h" 23 | puts "Rails MCP Server - MCP protocol server for Rails projects" 24 | puts "" 25 | puts "Usage: #{File.basename($0)} [options]" 26 | puts "" 27 | puts "Options:" 28 | puts " --log-level LEVEL Log level: debug, info, warn, error (default: info)" 29 | puts " --mode MODE Server mode: http or stdio (default: stdio)" 30 | puts " -p, --port PORT Port to listen on (default: 6029)" 31 | puts " version Display version information" 32 | puts " --help, -h Display this help message" 33 | puts "" 34 | puts "Example:" 35 | puts " #{File.basename($0)} --log-level debug -p 6060" 36 | puts " #{File.basename($0)} --mode stdio" 37 | exit 0 38 | end 39 | 40 | # Default values 41 | port = 6029 42 | mode = "stdio" 43 | 44 | # Parse command-line arguments 45 | i = 0 46 | while i < ARGV.length 47 | case ARGV[i] 48 | when "--log-level" 49 | log_level = ARGV[i + 1].to_sym 50 | i += 2 51 | when "-p", "--port" 52 | port = ARGV[i + 1].to_i 53 | i += 2 54 | when "--mode" 55 | mode = ARGV[i + 1].downcase 56 | unless ["http", "stdio"].include?(mode) # rubocop:disable Performance/CollectionLiteralInLoop 57 | puts "Error: Invalid mode '#{mode}'. Must be 'http' or 'stdio'." 58 | exit 1 59 | end 60 | i += 2 61 | else 62 | i += 1 63 | end 64 | end 65 | 66 | RailsMcpServer.config.log_level = log_level 67 | RailsMcpServer.log(:info, "Starting Rails MCP Server in #{mode} mode...") 68 | 69 | # Create tools configuration for both modes 70 | def setup_mcp_tools(server) 71 | server.register_tools(RailsMcpServer::SwitchProject, RailsMcpServer::ProjectInfo, 72 | RailsMcpServer::ListFiles, RailsMcpServer::GetFile, RailsMcpServer::GetRoutes, RailsMcpServer::AnalyzeModels, 73 | RailsMcpServer::GetSchema, RailsMcpServer::AnalyzeControllerViews, RailsMcpServer::AnalyzeEnvironmentConfig, 74 | RailsMcpServer::LoadGuide) 75 | 76 | server.register_resources(RailsMcpServer::RailsGuidesResource, RailsMcpServer::RailsGuidesResources, 77 | RailsMcpServer::StimulusGuidesResource, RailsMcpServer::StimulusGuidesResources, RailsMcpServer::TurboGuidesResource, 78 | RailsMcpServer::TurboGuidesResources, RailsMcpServer::CustomGuidesResource, RailsMcpServer::CustomGuidesResources, 79 | RailsMcpServer::KamalGuidesResource, RailsMcpServer::KamalGuidesResources) 80 | end 81 | 82 | case mode 83 | when "http" 84 | puts "Starting Rack application with MCP middleware on http://localhost:#{port}" 85 | puts "MCP endpoints:" 86 | puts " - http://localhost:#{port}/mcp/sse (SSE endpoint)" 87 | puts " - http://localhost:#{port}/mcp/messages (JSON-RPC endpoint)" 88 | puts "" 89 | puts "Version #{RailsMcpServer::VERSION}" 90 | puts "" 91 | 92 | rack_app = ->(env) { 93 | [200, {"Content-Type" => "text/plain"}, ["Rails MCP Server #{RailsMcpServer::VERSION}"]] 94 | } 95 | 96 | mcp_app = FastMcp.rack_middleware( 97 | rack_app, 98 | name: "rails-mcp-server", version: RailsMcpServer::VERSION, 99 | logger: RailsMcpServer.logger 100 | ) { |server| setup_mcp_tools(server) } 101 | 102 | app = Rack::Builder.new { run mcp_app } 103 | config = Puma::Configuration.new do |user_config| 104 | user_config.bind "tcp://localhost:#{port}" 105 | user_config.app app 106 | end 107 | 108 | launcher = Puma::Launcher.new(config) 109 | launcher.run 110 | when "stdio" 111 | RailsMcpServer.log(:info, "Starting MCP server in STDIO mode...") 112 | 113 | server = FastMcp::Server.new(name: "rails-mcp-server", version: RailsMcpServer::VERSION) 114 | setup_mcp_tools(server) 115 | 116 | server.start 117 | end 118 | 119 | RailsMcpServer.log(:info, "Stopping Rails MCP Server...") 120 | exit 121 | -------------------------------------------------------------------------------- /exe/rails-mcp-server-download-resources: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "optparse" 4 | require_relative "../lib/rails-mcp-server/config" 5 | require_relative "../lib/rails-mcp-server/helpers/resource_downloader" 6 | require_relative "../lib/rails-mcp-server/helpers/resource_importer" 7 | 8 | def show_help(parser, config_dir) 9 | puts parser 10 | puts "\nAvailable resources:" 11 | 12 | available = RailsMcpServer::ResourceDownloader.available_resources(config_dir) 13 | if available.any? 14 | available.each { |resource| puts " - #{resource}" } 15 | else 16 | puts " No resources configured" 17 | end 18 | 19 | puts "\nOr use --file to import custom markdown files" 20 | end 21 | 22 | def print_summary(action, results) 23 | puts "\n#{action} Summary:" 24 | if action == "Import" 25 | puts " Imported: #{results[:imported]}" 26 | else 27 | puts " Downloaded: #{results[:downloaded]}" 28 | end 29 | puts " Skipped: #{results[:skipped]}" 30 | puts " Failed: #{results[:failed]}" 31 | end 32 | 33 | options = { 34 | force: false, 35 | verbose: false, 36 | file: nil 37 | } 38 | 39 | parser = OptionParser.new do |opts| 40 | opts.banner = "Usage: rails-mcp-server-download-resources [options] RESOURCE_NAME" 41 | opts.separator "" 42 | opts.separator "Downloads documentation resources for Rails MCP Server" 43 | opts.separator "" 44 | 45 | opts.on("-f", "--force", "Force download/import even if files haven't changed") do 46 | options[:force] = true 47 | end 48 | 49 | opts.on("-v", "--verbose", "Verbose output") do 50 | options[:verbose] = true 51 | end 52 | 53 | opts.on("--file PATH", "Import custom markdown file(s) from PATH") do |path| 54 | options[:file] = path 55 | end 56 | 57 | opts.on("-h", "--help", "Show this help message") do 58 | puts opts 59 | puts "" 60 | puts "Examples:" 61 | puts " rails-mcp-server-download-resources rails" 62 | puts " rails-mcp-server-download-resources --file /path/to/guide.md" 63 | puts " rails-mcp-server-download-resources --file /path/to/guides/" 64 | puts " rails-mcp-server-download-resources --verbose --force stimulus" 65 | exit 66 | end 67 | end 68 | 69 | parser.parse! 70 | 71 | begin 72 | # Get config directory without requiring Rails projects 73 | config_dir = File.expand_path("~/.config/rails-mcp") 74 | 75 | # Handle file import 76 | if options[:file] 77 | unless File.exist?(options[:file]) 78 | puts "Error: File or directory not found: #{options[:file]}" 79 | exit 1 80 | end 81 | 82 | importer = RailsMcpServer::ResourceImporter.new( 83 | "custom", 84 | config_dir: config_dir, 85 | source_path: options[:file], 86 | force: options[:force], 87 | verbose: options[:verbose] 88 | ) 89 | 90 | results = importer.import 91 | print_summary("Import", results) 92 | exit (results[:failed] > 0) ? 1 : 0 93 | end 94 | 95 | # Handle resource download 96 | if ARGV.empty? 97 | show_help(parser, config_dir) 98 | exit 1 99 | end 100 | 101 | resource_name = ARGV[0] 102 | downloader = RailsMcpServer::ResourceDownloader.new( 103 | resource_name, 104 | config_dir: config_dir, 105 | force: options[:force], 106 | verbose: options[:verbose] 107 | ) 108 | 109 | results = downloader.download 110 | print_summary("Download", results) 111 | exit (results[:failed] > 0) ? 1 : 0 112 | rescue RailsMcpServer::ResourceDownloader::DownloadError, 113 | RailsMcpServer::ResourceImporter::ImportError => e 114 | puts "Error: #{e.message}" 115 | exit 1 116 | rescue => e 117 | puts "Unexpected error: #{e.message}" 118 | puts e.backtrace if options[:verbose] 119 | exit 1 120 | end 121 | -------------------------------------------------------------------------------- /exe/rails-mcp-setup-claude: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "pathname" 4 | require "fileutils" 5 | require "json" 6 | 7 | # ANSI color codes 8 | class String 9 | def colorize(color_code) 10 | "\e[#{color_code}m#{self}\e[0m" 11 | end 12 | 13 | def green 14 | colorize(32) 15 | end 16 | 17 | def blue 18 | colorize(34) 19 | end 20 | 21 | def yellow 22 | colorize(33) 23 | end 24 | 25 | def red 26 | colorize(31) 27 | end 28 | end 29 | 30 | # Get the absolute path to the project directory 31 | project_dir = Pathname.new(__FILE__).dirname.parent.expand_path 32 | 33 | # Print banner 34 | puts "============================================".blue 35 | puts " Rails MCP Server Setup for Claude ".blue 36 | puts "============================================".blue 37 | puts 38 | 39 | # Determine the Rails MCP config directory based on platform 40 | def get_rails_mcp_config_dir 41 | if RUBY_PLATFORM.match?(/mswin|mingw|cygwin/) 42 | # Windows 43 | File.join(ENV["APPDATA"], "rails-mcp") 44 | else 45 | # Linux/Unix - honor XDG_CONFIG_HOME 46 | xdg_config_home = ENV["XDG_CONFIG_HOME"] 47 | if xdg_config_home && !xdg_config_home.empty? 48 | File.join(xdg_config_home, "rails-mcp") 49 | else 50 | File.join(Dir.home, ".config", "rails-mcp") 51 | end 52 | end 53 | end 54 | 55 | rails_mcp_config_dir = get_rails_mcp_config_dir 56 | rails_mcp_log_dir = File.join(rails_mcp_config_dir, "log") 57 | 58 | # Create config and log directories 59 | FileUtils.mkdir_p(rails_mcp_log_dir) 60 | puts "✓".green + " Created Rails MCP config directory: #{rails_mcp_config_dir}" 61 | puts "✓".green + " Created Rails MCP log directory: #{rails_mcp_log_dir}" 62 | 63 | # Create projects.yml in the config dir if it doesn't exist 64 | projects_yml = File.join(rails_mcp_config_dir, "projects.yml") 65 | 66 | if !File.exist?(projects_yml) 67 | # Create a new projects.yml with explanatory comments 68 | File.write(projects_yml, "# Rails MCP Projects\n# Format: project_name: /path/to/project\n") 69 | puts "✓".green + " Created projects.yml file" 70 | puts "!".yellow + " Please edit #{projects_yml} to add your Rails projects" 71 | else 72 | puts "✓".green + " projects.yml already exists at #{projects_yml}" 73 | end 74 | 75 | # Detect OS for Claude Desktop config 76 | claude_config_dir = if RUBY_PLATFORM.match?(/mswin|mingw|cygwin/) 77 | File.join(ENV["APPDATA"], "Claude") 78 | else 79 | File.expand_path("~/Library/Application Support/Claude") 80 | end 81 | claude_config_file = File.join(claude_config_dir, "claude_desktop_config.json") 82 | 83 | puts 84 | puts "This script will configure Claude Desktop to use your Rails MCP Server.".blue 85 | puts "This will modify: #{claude_config_file}".blue 86 | puts "Rails MCP config directory: #{rails_mcp_config_dir}".blue 87 | puts 88 | 89 | # Ask for confirmation 90 | print "Do you want to continue? (y/n) " 91 | unless gets.chomp.casecmp("y").zero? 92 | puts "Setup canceled.".yellow 93 | exit(0) 94 | end 95 | 96 | # Create the config directory if it doesn't exist 97 | FileUtils.mkdir_p(claude_config_dir) 98 | 99 | # Full path to the rails-mcp-server executable 100 | server_path = File.join(project_dir, "exe", "rails-mcp-server") 101 | 102 | # Ensure the server is executable 103 | FileUtils.chmod("+x", server_path) 104 | puts "✓".green + " Made server executable" 105 | 106 | # Create or update the Claude Desktop config file 107 | config = {} 108 | 109 | if File.exist?(claude_config_file) 110 | begin 111 | config = JSON.parse(File.read(claude_config_file)) 112 | rescue JSON::ParserError 113 | puts "Error:".red + " Existing config file is not valid JSON. Creating backup and new config." 114 | FileUtils.cp(claude_config_file, "#{claude_config_file}.bak") 115 | config = {} 116 | end 117 | end 118 | 119 | # Update configuration 120 | config["mcpServers"] ||= {} 121 | # config["mcpServers"]["railsMcpServer"] = {"command" => "ruby", "args" => [server_path]} 122 | config["mcpServers"]["railsMcpServer"] = {"command" => server_path, "args" => []} 123 | 124 | # Write configuration back to file 125 | File.write(claude_config_file, JSON.pretty_generate(config)) 126 | 127 | puts "✓".green + " Updated Claude Desktop configuration" 128 | puts 129 | puts "Setup completed!".blue 130 | puts "!".yellow + " Please restart Claude Desktop to apply the changes." 131 | puts 132 | puts "Your Rails MCP Server is now configured at:".blue 133 | puts " #{server_path}" 134 | puts 135 | puts "Using config:".blue 136 | puts " Projects file: #{projects_yml}" 137 | puts " Log directory: #{rails_mcp_log_dir}" 138 | puts 139 | puts "To use it in Claude Desktop:".blue 140 | puts "1. Restart Claude Desktop" 141 | puts "2. Click on the plugin icon in the chat interface" 142 | puts "3. Select 'Rails MCP Server' from the list" 143 | puts 144 | puts "!".yellow + " Don't forget to edit your projects.yml file to add your Rails projects:" 145 | puts " #{projects_yml}" 146 | puts 147 | puts "============================================".blue 148 | -------------------------------------------------------------------------------- /lib/rails-mcp-server/config.rb: -------------------------------------------------------------------------------- 1 | require "logger" 2 | 3 | module RailsMcpServer 4 | class Config 5 | attr_accessor :logger, :projects, :current_project, :active_project_path, :config_dir 6 | attr_reader :log_level 7 | 8 | def self.setup 9 | new.tap do |instance| 10 | yield(instance) if block_given? 11 | end 12 | end 13 | 14 | def initialize 15 | @log_level = Logger::INFO 16 | @config_dir = get_config_dir 17 | @current_project = nil 18 | @active_project_path = nil 19 | 20 | configure_logger 21 | load_projects 22 | end 23 | 24 | def log_level=(level) 25 | @log_level = LEVELS[level] || Logger::INFO 26 | @logger.level = @log_level 27 | end 28 | 29 | private 30 | 31 | def load_projects 32 | projects_file = File.join(@config_dir, "projects.yml") 33 | @projects = {} 34 | 35 | @logger.add(Logger::INFO, "Loading projects from: #{projects_file}") 36 | 37 | # Create empty projects file if it doesn't exist 38 | unless File.exist?(projects_file) 39 | @logger.add(Logger::INFO, "Creating empty projects file: #{projects_file}") 40 | FileUtils.mkdir_p(File.dirname(projects_file)) 41 | File.write(projects_file, "# Rails MCP Projects\n# Format: project_name: /path/to/project\n") 42 | end 43 | 44 | @projects = YAML.load_file(projects_file) || {} 45 | found_projects_size = @projects.size 46 | @logger.add(Logger::INFO, "Loaded #{found_projects_size} projects: #{@projects.keys.join(", ")}") 47 | 48 | if found_projects_size.zero? 49 | message = "No projects found.\nPlease add a project to #{projects_file} and try again." 50 | puts message 51 | @logger.add(Logger::ERROR, message) 52 | exit 1 53 | end 54 | end 55 | 56 | def configure_logger 57 | FileUtils.mkdir_p(File.join(@config_dir, "log")) 58 | log_file = File.join(@config_dir, "log", "rails_mcp_server.log") 59 | 60 | @logger = Logger.new(log_file) 61 | @logger.level = @log_level 62 | 63 | @logger.formatter = proc do |severity, datetime, progname, msg| 64 | "[#{datetime.strftime("%Y-%m-%d %H:%M:%S")}] #{severity}: #{msg}\n" 65 | end 66 | end 67 | 68 | def get_config_dir 69 | # Use XDG_CONFIG_HOME if set, otherwise use ~/.config 70 | xdg_config_home = ENV["XDG_CONFIG_HOME"] 71 | if xdg_config_home && !xdg_config_home.empty? 72 | File.join(xdg_config_home, "rails-mcp") 73 | else 74 | File.join(Dir.home, ".config", "rails-mcp") 75 | end 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /lib/rails-mcp-server/extensions/resource_templating.rb: -------------------------------------------------------------------------------- 1 | module RailsMcpServer 2 | module Extensions 3 | # Extension module to add URI templating capabilities to FastMcp::Resource 4 | # Uses module prepending for clean method override behavior 5 | module ResourceTemplating 6 | # Class methods to be prepended to the singleton class 7 | module ClassMethods 8 | attr_reader :template_params 9 | 10 | def variabilized_uri(params = {}) 11 | addressable_template.partial_expand(params).pattern 12 | end 13 | 14 | def addressable_template 15 | @addressable_template ||= Addressable::Template.new(uri) 16 | end 17 | 18 | def template_variables 19 | addressable_template.variables 20 | end 21 | 22 | def templated? 23 | template_variables.any? 24 | end 25 | 26 | def non_templated? 27 | !templated? 28 | end 29 | 30 | def match(uri) 31 | addressable_template.match(uri) 32 | end 33 | 34 | def initialize_from_uri(uri) 35 | new(params_from_uri(uri)) 36 | end 37 | 38 | def params_from_uri(uri) 39 | match(uri).mapping.transform_keys(&:to_sym) 40 | end 41 | 42 | def instance(uri = self.uri) 43 | @instances ||= {} 44 | @instances[uri] ||= begin 45 | resource_class = Class.new(self) 46 | params = params_from_uri(uri) 47 | resource_class.instance_variable_set(:@params, params) 48 | 49 | resource_class.define_singleton_method(:instance) do 50 | @instance ||= begin 51 | instance = new 52 | instance.instance_variable_set(:@params, params) 53 | instance 54 | end 55 | end 56 | 57 | resource_class.instance 58 | end 59 | end 60 | 61 | def params 62 | @params || {} 63 | end 64 | 65 | def name 66 | return resource_name if resource_name 67 | super 68 | end 69 | 70 | def metadata 71 | if templated? 72 | { 73 | uriTemplate: uri, 74 | name: resource_name, 75 | description: description, 76 | mimeType: mime_type 77 | }.compact 78 | else 79 | super 80 | end 81 | end 82 | end 83 | 84 | # Instance methods to be prepended 85 | module InstanceMethods 86 | def initialize 87 | @params = self.class.params 88 | super if defined?(super) 89 | end 90 | 91 | def params 92 | @params || self.class.params 93 | end 94 | 95 | def name 96 | self.class.resource_name 97 | end 98 | end 99 | 100 | # Called when this module is prepended to a class 101 | def self.prepended(base) 102 | base.singleton_class.prepend(ClassMethods) 103 | base.prepend(InstanceMethods) 104 | end 105 | end 106 | 107 | # Main setup class for resource extensions 108 | class ResourceExtensionSetup 109 | class << self 110 | def setup! 111 | return if @setup_complete 112 | 113 | ensure_dependencies_loaded! 114 | apply_extensions! 115 | 116 | @setup_complete = true 117 | RailsMcpServer.log(:info, "FastMcp::Resource extensions loaded successfully") 118 | rescue => e 119 | RailsMcpServer.log(:error, "Failed to setup resource extensions: #{e.message}") 120 | raise 121 | end 122 | 123 | def reset! 124 | @setup_complete = false 125 | end 126 | 127 | def setup_complete? 128 | @setup_complete || false 129 | end 130 | 131 | private 132 | 133 | def ensure_dependencies_loaded! 134 | # Check that FastMcp::Resource exists 135 | unless defined?(FastMcp::Resource) 136 | begin 137 | require "fast-mcp" 138 | rescue LoadError => e 139 | raise LoadError, "fast-mcp gem is required but not available. Ensure it's in your Gemfile: #{e.message}" 140 | end 141 | end 142 | 143 | # Verify the expected interface exists 144 | unless FastMcp::Resource.respond_to?(:uri) 145 | raise "FastMcp::Resource doesn't have expected interface. Check fast-mcp gem version." 146 | end 147 | 148 | # Load addressable template dependency 149 | begin 150 | require "addressable/template" 151 | rescue LoadError => e 152 | raise LoadError, "addressable gem is required for URI templating: #{e.message}" 153 | end 154 | 155 | # Optional: Version checking 156 | if defined?(FastMcp::VERSION) 157 | version = Gem::Version.new(FastMcp::VERSION) 158 | minimum_version = Gem::Version.new("1.4.0") 159 | 160 | if version < minimum_version 161 | RailsMcpServer.log(:warn, "FastMcp version #{FastMcp::VERSION} detected. Extensions tested with #{minimum_version}+") 162 | end 163 | end 164 | end 165 | 166 | def apply_extensions! 167 | # Apply extensions to FastMcp::Resource 168 | FastMcp::Resource.prepend(ResourceTemplating) 169 | 170 | # Also ensure our BaseResource gets the extensions 171 | if defined?(RailsMcpServer::BaseResource) 172 | # BaseResource already inherits from FastMcp::Resource, so it gets extensions automatically 173 | RailsMcpServer.log(:debug, "BaseResource will inherit templating extensions") 174 | end 175 | 176 | # Setup server extensions as well 177 | RailsMcpServer::Extensions::ServerExtensionSetup.setup! 178 | end 179 | end 180 | end 181 | end 182 | end 183 | -------------------------------------------------------------------------------- /lib/rails-mcp-server/extensions/server_templating.rb: -------------------------------------------------------------------------------- 1 | module RailsMcpServer 2 | module Extensions 3 | # Extension module to add any missing templated resource support to FastMcp::Server 4 | # This version of the server already has most templated resource functionality 5 | module ServerTemplating 6 | # Instance methods to be prepended 7 | module InstanceMethods 8 | # The target server already has most functionality, but we can add defensive checks 9 | def read_resource(uri) 10 | # Handle both hash-based and array-based resource storage 11 | if @resources.is_a?(Hash) 12 | # First try exact match (hash lookup) 13 | exact_match = @resources[uri] 14 | return exact_match if exact_match 15 | 16 | # Then try templated resource matching 17 | @resources.values.find { |r| r.respond_to?(:match) && r.match(uri) } 18 | else 19 | # Array-based storage (original target server behavior) 20 | resource = @resources.find { |r| r.respond_to?(:match) && r.match(uri) } 21 | 22 | # Fallback: if no templated match, try exact URI match for backward compatibility 23 | resource ||= @resources.find { |r| r.respond_to?(:uri) && r.uri == uri } 24 | 25 | resource 26 | end 27 | end 28 | 29 | # Add some defensive programming to handle_resources_read 30 | def handle_resources_read(params, id) 31 | uri = params["uri"] 32 | 33 | return send_error(-32_602, "Invalid params: missing resource URI", id) unless uri 34 | 35 | @logger.debug("Looking for resource with URI: #{uri}") 36 | 37 | begin 38 | resource = read_resource(uri) 39 | return send_error(-32_602, "Resource not found: #{uri}", id) unless resource 40 | 41 | # Defensive check for templated method 42 | is_templated = resource.respond_to?(:templated?) ? resource.templated? : false 43 | @logger.debug("Found resource: #{resource.respond_to?(:resource_name) ? resource.resource_name : resource.name}, templated: #{is_templated}") 44 | 45 | base_content = {uri: uri} 46 | base_content[:mimeType] = resource.mime_type if resource.mime_type 47 | 48 | # Handle both templated and non-templated resources 49 | resource_instance = if is_templated && resource.respond_to?(:instance) 50 | resource.instance(uri) 51 | else 52 | # Fallback for non-templated resources or resources without instance method 53 | resource.respond_to?(:instance) ? resource.instance : resource 54 | end 55 | 56 | # Defensive check for params method 57 | if resource_instance.respond_to?(:params) 58 | @logger.debug("Resource instance params: #{resource_instance.params.inspect}") 59 | end 60 | 61 | result = if resource_instance.respond_to?(:binary?) && resource_instance.binary? 62 | { 63 | contents: [base_content.merge(blob: Base64.strict_encode64(resource_instance.content))] 64 | } 65 | else 66 | { 67 | contents: [base_content.merge(text: resource_instance.content)] 68 | } 69 | end 70 | 71 | send_result(result, id) 72 | rescue => e 73 | @logger.error("Error reading resource: #{e.message}") 74 | @logger.error(e.backtrace.join("\n")) 75 | send_error(-32_600, "Internal error reading resource: #{e.message}", id) 76 | end 77 | end 78 | 79 | # The target server already has these methods, but we can add defensive checks 80 | def handle_resources_list(id) 81 | # Handle both hash-based and array-based resource storage 82 | resources_collection = @resources.is_a?(Hash) ? @resources.values : @resources 83 | 84 | resources_list = resources_collection.select do |resource| 85 | !resource.respond_to?(:templated?) || resource.non_templated? 86 | end.map(&:metadata) # rubocop:disable Performance/ChainArrayAllocation 87 | 88 | send_result({resources: resources_list}, id) 89 | end 90 | 91 | def handle_resources_templates_list(id) 92 | @logger.debug("Handling resources/templates/list request") 93 | 94 | # Handle both hash-based and array-based resource storage 95 | resources_collection = @resources.is_a?(Hash) ? @resources.values : @resources 96 | 97 | templated_resources_list = resources_collection.select do |resource| 98 | resource.respond_to?(:templated?) && resource.templated? 99 | end.map do |resource| # rubocop:disable Performance/ChainArrayAllocation 100 | metadata = resource.metadata 101 | @logger.debug("Template resource metadata: #{metadata}") 102 | metadata 103 | end 104 | 105 | @logger.info("Returning #{templated_resources_list.length} templated resources") 106 | send_result({resourceTemplates: templated_resources_list}, id) 107 | end 108 | 109 | # Override handle_request to ensure resources/templates/list endpoint is available 110 | def handle_request(*args) 111 | # Extract arguments - handle different signatures 112 | if args.length == 2 113 | json_str, headers = args 114 | headers ||= {} 115 | else 116 | json_str = args[0] 117 | headers = {} 118 | end 119 | 120 | begin 121 | request = JSON.parse(json_str) 122 | rescue JSON::ParserError, TypeError 123 | return send_error(-32_600, "Invalid Request", nil) 124 | end 125 | 126 | @logger.debug("Received request: #{request.inspect}") 127 | 128 | # Check if it's a valid JSON-RPC 2.0 request 129 | unless request["jsonrpc"] == "2.0" && request["method"] 130 | return send_error(-32_600, "Invalid Request", request["id"]) 131 | end 132 | 133 | method = request["method"] 134 | params = request["params"] || {} 135 | id = request["id"] 136 | 137 | # Handle the resources/templates/list endpoint specifically since it might not exist in original 138 | if method == "resources/templates/list" 139 | @logger.debug("Handling resources/templates/list via extension") 140 | return handle_resources_templates_list(id) 141 | end 142 | 143 | # For all other methods, call the original implementation 144 | begin 145 | super 146 | rescue NoMethodError => e 147 | # If super doesn't work, provide our own fallback 148 | @logger.debug("Original handle_request not available, using fallback: #{e.message}") 149 | handle_request_fallback(method, params, id, headers) 150 | end 151 | rescue => e 152 | @logger.error("Error handling request: #{e.message}, #{e.backtrace.join("\n")}") 153 | send_error(-32_600, "Internal error: #{e.message}", id) 154 | end 155 | 156 | private 157 | 158 | def handle_request_fallback(method, params, id, headers) 159 | @logger.debug("Using fallback handler for method: #{method}") 160 | 161 | case method 162 | when "ping" 163 | send_result({}, id) 164 | when "initialize" 165 | handle_initialize(params, id) 166 | when "notifications/initialized" 167 | handle_initialized_notification 168 | when "tools/list" 169 | handle_tools_list(id) 170 | when "tools/call" 171 | # Handle different method signatures for tools/call 172 | if method(:handle_tools_call).arity == 3 173 | handle_tools_call(params, headers, id) 174 | else 175 | handle_tools_call(params, id) 176 | end 177 | when "resources/list" 178 | handle_resources_list(id) 179 | when "resources/templates/list" 180 | handle_resources_templates_list(id) 181 | when "resources/read" 182 | handle_resources_read(params, id) 183 | when "resources/subscribe" 184 | handle_resources_subscribe(params, id) 185 | when "resources/unsubscribe" 186 | handle_resources_unsubscribe(params, id) 187 | else 188 | send_error(-32_601, "Method not found: #{method}", id) 189 | end 190 | end 191 | 192 | # Add defensive programming to resource subscription methods 193 | def handle_resources_subscribe(params, id) 194 | return unless @client_initialized 195 | 196 | uri = params["uri"] 197 | 198 | unless uri 199 | send_error(-32_602, "Invalid params: missing resource URI", id) 200 | return 201 | end 202 | 203 | # Use the read_resource method which supports templated resources 204 | resource = read_resource(uri) 205 | return send_error(-32_602, "Resource not found: #{uri}", id) unless resource 206 | 207 | # Add to subscriptions 208 | @resource_subscriptions[uri] ||= [] 209 | @resource_subscriptions[uri] << id 210 | 211 | send_result({subscribed: true}, id) 212 | end 213 | 214 | # Enhanced logging for resource registration 215 | def register_resource(resource) 216 | # Handle both hash-based and array-based resource storage 217 | if @resources.is_a?(Hash) 218 | @resources[resource.uri] = resource 219 | else 220 | @resources << resource 221 | end 222 | 223 | resource_name = if resource.respond_to?(:resource_name) 224 | resource.resource_name 225 | else 226 | (resource.respond_to?(:name) ? resource.name : "Unknown") 227 | end 228 | is_templated = resource.respond_to?(:templated?) ? resource.templated? : false 229 | 230 | @logger.debug("Registered resource: #{resource_name} (#{resource.uri}) - Templated: #{is_templated}") 231 | resource.server = self if resource.respond_to?(:server=) 232 | 233 | # Notify subscribers about the list change 234 | notify_resource_list_changed if @transport 235 | 236 | resource 237 | end 238 | end 239 | 240 | # Called when this module is prepended to a class 241 | def self.prepended(base) 242 | base.prepend(InstanceMethods) 243 | end 244 | end 245 | 246 | # Setup class for server extensions 247 | class ServerExtensionSetup 248 | class << self 249 | def setup! 250 | return if @setup_complete 251 | 252 | ensure_dependencies_loaded! 253 | check_server_compatibility! 254 | apply_extensions_if_needed! 255 | 256 | @setup_complete = true 257 | RailsMcpServer.log(:info, "FastMcp::Server extensions checked and applied if needed") 258 | rescue => e 259 | RailsMcpServer.log(:error, "Failed to setup server extensions: #{e.message}") 260 | raise 261 | end 262 | 263 | def reset! 264 | @setup_complete = false 265 | end 266 | 267 | def setup_complete? 268 | @setup_complete || false 269 | end 270 | 271 | private 272 | 273 | def ensure_dependencies_loaded! 274 | # Check that FastMcp::Server exists 275 | unless defined?(FastMcp::Server) 276 | begin 277 | require "fast-mcp" 278 | rescue LoadError => e 279 | raise LoadError, "fast-mcp gem is required but not available: #{e.message}" 280 | end 281 | end 282 | 283 | # Verify the expected interface exists 284 | unless FastMcp::Server.instance_methods.include?(:read_resource) 285 | raise "FastMcp::Server doesn't have expected read_resource method. Check fast-mcp gem version." 286 | end 287 | 288 | # Check handle_request method signature 289 | handle_request_method = FastMcp::Server.instance_method(:handle_request) 290 | arity = handle_request_method.arity 291 | RailsMcpServer.log(:debug, "FastMcp::Server#handle_request arity: #{arity}") 292 | 293 | # Check if resources/templates/list is already supported 294 | test_server = FastMcp::Server.new(name: "test", version: "1.0.0") 295 | has_templates_method = test_server.respond_to?(:handle_resources_templates_list) 296 | RailsMcpServer.log(:debug, "Original server has handle_resources_templates_list: #{has_templates_method}") 297 | end 298 | 299 | def check_server_compatibility! 300 | # Check if the server already has templated resource support 301 | server_instance = FastMcp::Server.new(name: "test", version: "1.0.0") 302 | 303 | @server_has_templates = server_instance.respond_to?(:handle_resources_templates_list) 304 | @server_has_advanced_read = begin 305 | # Check if read_resource method body includes 'match' 306 | method_source = FastMcp::Server.instance_method(:read_resource).source_location 307 | method_source ? true : false 308 | rescue 309 | false 310 | end 311 | 312 | RailsMcpServer.log(:debug, "Server template support detected: #{@server_has_templates}") 313 | RailsMcpServer.log(:debug, "Server advanced read support detected: #{@server_has_advanced_read}") 314 | end 315 | 316 | def apply_extensions_if_needed! 317 | # Always apply extensions to ensure resources/templates/list endpoint is available 318 | # The MCP inspector error shows this endpoint is missing 319 | RailsMcpServer.log(:info, "Applying server extensions to ensure full MCP compliance") 320 | FastMcp::Server.prepend(ServerTemplating) 321 | 322 | # Verify the extension was applied by checking if our methods are available 323 | test_server = FastMcp::Server.new(name: "test", version: "1.0.0") 324 | has_templates_list = test_server.respond_to?(:handle_resources_templates_list) 325 | RailsMcpServer.log(:info, "Server extension verification - handle_resources_templates_list available: #{has_templates_list}") 326 | rescue => e 327 | RailsMcpServer.log(:error, "Error applying server extensions: #{e.message}") 328 | raise 329 | end 330 | end 331 | end 332 | end 333 | end 334 | -------------------------------------------------------------------------------- /lib/rails-mcp-server/helpers/resource_base.rb: -------------------------------------------------------------------------------- 1 | require "fileutils" 2 | require "digest" 3 | require "yaml" 4 | 5 | module RailsMcpServer 6 | class ResourceBase 7 | attr_reader :resource_name, :config_dir, :resource_folder, :manifest_file 8 | 9 | def initialize(resource_name, config_dir:, force: false, verbose: false) 10 | @resource_name = resource_name.to_s 11 | @config_dir = config_dir 12 | @force = force 13 | @verbose = verbose 14 | setup_paths 15 | end 16 | 17 | protected 18 | 19 | def setup_paths 20 | @resource_folder = File.join(@config_dir, "resources", @resource_name) 21 | @manifest_file = File.join(@resource_folder, "manifest.yaml") 22 | end 23 | 24 | def setup_directories 25 | FileUtils.mkdir_p(@resource_folder) 26 | end 27 | 28 | def load_manifest 29 | @manifest = if File.exist?(@manifest_file) 30 | YAML.load_file(@manifest_file) 31 | else 32 | create_manifest 33 | end 34 | end 35 | 36 | def save_manifest 37 | @manifest["updated_at"] = Time.now.to_s 38 | File.write(@manifest_file, @manifest.to_yaml) 39 | end 40 | 41 | def file_unchanged?(filename, file_path) 42 | return false unless File.exist?(file_path) 43 | current_hash = file_hash(file_path) 44 | @manifest["files"][filename] && @manifest["files"][filename]["hash"] == current_hash 45 | end 46 | 47 | def save_file_to_manifest(filename, file_path, additional_data = {}) 48 | metadata = extract_metadata(File.read(file_path), filename) 49 | 50 | @manifest["files"][filename] = { 51 | "hash" => file_hash(file_path), 52 | "size" => File.size(file_path) 53 | }.merge(timestamp_key => Time.now.to_s) 54 | .merge(additional_data) 55 | .merge(metadata) 56 | end 57 | 58 | def extract_metadata(content, filename = nil) 59 | metadata = {} 60 | 61 | title = find_title(content) || (filename ? humanize_filename(filename) : nil) 62 | metadata["title"] = title if title 63 | 64 | description = find_description(content) 65 | metadata["description"] = description if description && !description.empty? 66 | 67 | metadata 68 | end 69 | 70 | def find_title(content) 71 | lines = content.lines 72 | 73 | # H1 header 74 | lines.each do |line| 75 | return $1.strip if line.strip =~ /^#\s+(.+)$/ 76 | end 77 | 78 | # Underlined title 79 | lines.each_with_index do |line, index| 80 | next if index >= lines.length - 1 81 | return line.strip if /^=+$/.match?(lines[index + 1].strip) 82 | end 83 | 84 | nil 85 | end 86 | 87 | def find_description(content) 88 | # Clean content 89 | clean = content.dup 90 | clean = clean.sub(/^---\s*\n.*?\n---\s*\n/m, "") # Remove YAML frontmatter 91 | clean = clean.gsub(/^#\s+.*?\n/, "") # Remove H1 headers 92 | clean = clean.gsub(/^.+\n=+\s*\n/, "") # Remove underlined titles 93 | clean = clean.strip.gsub(/\n+/, " ").gsub(/\s+/, " ") 94 | 95 | return "" if clean.empty? 96 | 97 | if clean.length > 200 98 | truncate_at = clean.rindex(" ", 200) || 200 99 | clean[0...truncate_at] + "..." 100 | else 101 | clean 102 | end 103 | end 104 | 105 | def humanize_filename(filename) 106 | base = File.basename(filename, File.extname(filename)) 107 | 108 | title = base.gsub(/[_-]/, " ") 109 | .gsub(/^\d+[.\-_\s]*/, "") 110 | .split(" ").map(&:capitalize).join(" ") 111 | 112 | # Common abbreviations 113 | replacements = { 114 | /\bApi\b/ => "API", /\bHtml\b/ => "HTML", /\bCss\b/ => "CSS", 115 | /\bJs\b/ => "JavaScript", /\bUi\b/ => "UI", /\bUrl\b/ => "URL", 116 | /\bRest\b/ => "REST", /\bJson\b/ => "JSON", /\bXml\b/ => "XML", 117 | /\bSql\b/ => "SQL" 118 | } 119 | 120 | replacements.each { |pattern, replacement| title = title.gsub(pattern, replacement) } 121 | 122 | title.strip.empty? ? "Untitled Guide" : title 123 | end 124 | 125 | def file_hash(file_path) 126 | Digest::SHA256.file(file_path).hexdigest 127 | end 128 | 129 | def log(message, newline: true) 130 | return unless @verbose 131 | newline ? puts(message) : print(message) 132 | end 133 | 134 | # Abstract methods to be implemented by subclasses 135 | def create_manifest 136 | raise NotImplementedError 137 | end 138 | 139 | def timestamp_key 140 | raise NotImplementedError 141 | end 142 | end 143 | end 144 | -------------------------------------------------------------------------------- /lib/rails-mcp-server/helpers/resource_downloader.rb: -------------------------------------------------------------------------------- 1 | require "net/http" 2 | require "uri" 3 | require_relative "resource_base" 4 | 5 | module RailsMcpServer 6 | class ResourceDownloader < ResourceBase 7 | class DownloadError < StandardError; end 8 | 9 | def initialize(resource_name, config_dir:, force: false, verbose: false) 10 | super 11 | load_config 12 | end 13 | 14 | def download 15 | setup_directories 16 | load_manifest 17 | 18 | log "Downloading #{@resource_name} resources..." 19 | 20 | results = {downloaded: 0, skipped: 0, failed: 0} 21 | 22 | @config["files"].each do |file| 23 | result = download_file(file) 24 | results[result] += 1 25 | end 26 | 27 | save_manifest 28 | results 29 | end 30 | 31 | def self.available_resources(config_dir) 32 | config_file = File.join(File.dirname(__FILE__), "..", "..", "..", "config", "resources.yml") 33 | return [] unless File.exist?(config_file) 34 | 35 | YAML.load_file(config_file).keys 36 | rescue => e 37 | warn "Failed to load resource configuration: #{e.message}" 38 | [] 39 | end 40 | 41 | protected 42 | 43 | def create_manifest 44 | { 45 | "resource" => @resource_name, 46 | "base_url" => @config["base_url"], 47 | "description" => @config["description"], 48 | "version" => @config["version"], 49 | "files" => {}, 50 | "created_at" => Time.now.to_s, 51 | "updated_at" => Time.now.to_s 52 | } 53 | end 54 | 55 | def timestamp_key 56 | "downloaded_at" 57 | end 58 | 59 | private 60 | 61 | def load_config 62 | config_file = File.join(File.dirname(__FILE__), "..", "..", "..", "config", "resources.yml") 63 | 64 | raise DownloadError, "Resource configuration file not found" unless File.exist?(config_file) 65 | 66 | all_configs = YAML.load_file(config_file) 67 | @config = all_configs[@resource_name] 68 | 69 | raise DownloadError, "Unknown resource: #{@resource_name}" unless @config 70 | end 71 | 72 | def download_file(filename) 73 | file_path = File.join(@resource_folder, filename) 74 | url = "#{@config["base_url"]}/#{filename}" 75 | 76 | # Skip if unchanged 77 | if !@force && file_unchanged?(filename, file_path) 78 | log "Skipping #{filename} (unchanged)" 79 | return :skipped 80 | end 81 | 82 | log "Downloading #{filename}... ", newline: false 83 | 84 | begin 85 | uri = URI(url) 86 | response = Net::HTTP.get_response(uri) 87 | 88 | if response.code == "200" 89 | FileUtils.mkdir_p(File.dirname(file_path)) 90 | File.write(file_path, response.body) 91 | save_file_to_manifest(filename, file_path) 92 | log "done" 93 | :downloaded 94 | else 95 | log "failed (HTTP #{response.code})" 96 | :failed 97 | end 98 | rescue => e 99 | log "failed (#{e.message})" 100 | :failed 101 | end 102 | end 103 | end 104 | end 105 | -------------------------------------------------------------------------------- /lib/rails-mcp-server/helpers/resource_importer.rb: -------------------------------------------------------------------------------- 1 | require_relative "resource_base" 2 | 3 | module RailsMcpServer 4 | class ResourceImporter < ResourceBase 5 | class ImportError < StandardError; end 6 | 7 | def initialize(resource_name, config_dir:, source_path:, force: false, verbose: false) 8 | @source_path = source_path 9 | super(resource_name, config_dir: config_dir, force: force, verbose: verbose) 10 | validate_source 11 | end 12 | 13 | def import 14 | setup_directories 15 | load_manifest 16 | 17 | log "Importing custom files from #{@source_path}..." 18 | 19 | results = {imported: 0, skipped: 0, failed: 0} 20 | files = collect_files 21 | 22 | if files.empty? 23 | log "No markdown files found" 24 | save_manifest 25 | return results 26 | end 27 | 28 | files.each do |file_path| 29 | result = import_file(file_path) 30 | results[result] += 1 31 | end 32 | 33 | save_manifest 34 | results 35 | end 36 | 37 | protected 38 | 39 | def create_manifest 40 | { 41 | "resource" => @resource_name, 42 | "base_url" => "local", 43 | "description" => "Custom imported documentation", 44 | "files" => {}, 45 | "created_at" => Time.now.to_s, 46 | "updated_at" => Time.now.to_s 47 | } 48 | end 49 | 50 | def timestamp_key 51 | "imported_at" 52 | end 53 | 54 | private 55 | 56 | def validate_source 57 | raise ImportError, "Source not found: #{@source_path}" unless File.exist?(@source_path) 58 | raise ImportError, "Source not readable: #{@source_path}" unless File.readable?(@source_path) 59 | end 60 | 61 | def collect_files 62 | if File.file?(@source_path) 63 | markdown?(@source_path) ? [@source_path] : [] 64 | elsif File.directory?(@source_path) 65 | Dir.glob(File.join(@source_path, "*.md")).select { |f| File.file?(f) }.sort # rubocop:disable Performance/ChainArrayAllocation 66 | else 67 | [] 68 | end 69 | end 70 | 71 | def markdown?(file_path) 72 | File.extname(file_path).casecmp(".md").zero? 73 | end 74 | 75 | def import_file(file_path) 76 | original_filename = File.basename(file_path) 77 | normalized_filename = normalize_filename(original_filename) 78 | destination_path = File.join(@resource_folder, normalized_filename) 79 | 80 | # Skip if unchanged 81 | if !@force && file_unchanged?(normalized_filename, file_path) 82 | log "Skipping #{original_filename} (unchanged)" 83 | return :skipped 84 | end 85 | 86 | log "Importing #{original_filename} -> #{normalized_filename}... ", newline: false 87 | 88 | begin 89 | FileUtils.cp(file_path, destination_path) 90 | save_file_to_manifest(normalized_filename, destination_path, 91 | {"original_filename" => original_filename}) 92 | log "done" 93 | :imported 94 | rescue => e 95 | log "failed (#{e.message})" 96 | :failed 97 | end 98 | end 99 | 100 | def normalize_filename(filename) 101 | basename = File.basename(filename, ".md") 102 | extension = File.extname(filename) 103 | 104 | normalized = basename.downcase 105 | .gsub(/[^a-z0-9_\-.]/, "_") 106 | .squeeze("_") 107 | .gsub(/^_+|_+$/, "") 108 | 109 | normalized = "untitled" if normalized.empty? 110 | "#{normalized}#{extension}" 111 | end 112 | end 113 | end 114 | -------------------------------------------------------------------------------- /lib/rails-mcp-server/resources/base_resource.rb: -------------------------------------------------------------------------------- 1 | module RailsMcpServer 2 | class BaseResource < FastMcp::Resource 3 | extend Forwardable 4 | 5 | def_delegators :RailsMcpServer, :log, :config_dir 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/rails-mcp-server/resources/custom_guides_resource.rb: -------------------------------------------------------------------------------- 1 | module RailsMcpServer 2 | class CustomGuidesResource < BaseResource 3 | include GuideLoaderTemplate 4 | 5 | uri "custom://guides/{guide_name}" 6 | resource_name "Custom Guides" 7 | description "Access to specific custom imported documentation" 8 | mime_type "text/markdown" 9 | 10 | protected 11 | 12 | def framework_name 13 | "Custom" 14 | end 15 | 16 | def resource_directory 17 | "custom" 18 | end 19 | 20 | def download_command 21 | "rails-mcp-server-download-resources --file /path/to/files" 22 | end 23 | 24 | # Custom guides don't use handbook/reference sections (flat structure like Rails) 25 | def supports_sections? 26 | false 27 | end 28 | 29 | # Custom display name to show original filename 30 | def customize_display_name(guide_name, guide_data) 31 | guide_data["original_filename"] || guide_name 32 | end 33 | 34 | # Custom error message for imports 35 | def customize_not_found_message(message, guide_name) 36 | message + "\n**Note:** Make sure you've imported your custom guides with `#{download_command}`\n" 37 | end 38 | 39 | # Custom manifest error handling 40 | def handle_manifest_error(error) 41 | case error.message 42 | when /No Custom guides found/ 43 | format_error_message( 44 | "No custom guides found. Import guides with:\n" \ 45 | "`rails-mcp-server-download-resources --file /path/to/guide.md`\n" \ 46 | "or\n" \ 47 | "`rails-mcp-server-download-resources --file /path/to/guides/`" 48 | ) 49 | else 50 | super 51 | end 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/rails-mcp-server/resources/custom_guides_resources.rb: -------------------------------------------------------------------------------- 1 | module RailsMcpServer 2 | class CustomGuidesResources < BaseResource 3 | include GuideLoaderTemplate 4 | 5 | uri "custom://guides" 6 | resource_name "Custom Guides" 7 | description "Access to available custom imported guides" 8 | mime_type "text/markdown" 9 | 10 | protected 11 | 12 | def framework_name 13 | "Custom" 14 | end 15 | 16 | def resource_directory 17 | "custom" 18 | end 19 | 20 | def download_command 21 | "rails-mcp-server-download-resources --file /path/to/files" 22 | end 23 | 24 | def example_guides 25 | [ 26 | {guide: "api_documentation", comment: "Load API documentation"}, 27 | {guide: "setup_guide", comment: "Load setup instructions"}, 28 | {guide: "user_manual", comment: "Load user manual"} 29 | ] 30 | end 31 | 32 | # Custom guides don't use handbook/reference sections (flat structure like Rails) 33 | def supports_sections? 34 | false 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/rails-mcp-server/resources/guide_content_formatter.rb: -------------------------------------------------------------------------------- 1 | module RailsMcpServer 2 | # Module for formatting guide content and messages 3 | module GuideContentFormatter 4 | protected 5 | 6 | # Format the guide content with appropriate headers 7 | def format_guide_content(content, guide_name, guide_data, filename) 8 | title = guide_data["title"] || generate_title_from_filename(filename) 9 | 10 | header = if supports_sections? 11 | section = determine_section(filename) 12 | <<~HEADER 13 | # #{title} 14 | 15 | **Source:** #{framework_name} #{section} 16 | **Guide:** #{guide_name} 17 | **File:** #{filename} 18 | 19 | --- 20 | 21 | HEADER 22 | else 23 | <<~HEADER 24 | # #{title} 25 | 26 | **Source:** #{framework_name} Guides 27 | **Guide:** #{guide_name} 28 | 29 | --- 30 | 31 | HEADER 32 | end 33 | 34 | header + content 35 | end 36 | 37 | # Format individual guide entry for listings 38 | def format_guide_entry(title, short_name, full_name, description) 39 | if supports_sections? && short_name != full_name 40 | <<~GUIDE 41 | ### #{title} 42 | **Guide name:** `#{short_name}` or `#{full_name}` 43 | #{description.empty? ? "" : "**Description:** #{description}"} 44 | GUIDE 45 | else 46 | <<~GUIDE 47 | ## #{title} 48 | **Guide name:** `#{short_name}` 49 | #{description.empty? ? "" : "**Description:** #{description}"} 50 | GUIDE 51 | end 52 | end 53 | 54 | # Format usage examples section 55 | def format_usage_examples 56 | examples = example_guides 57 | 58 | usage = "\n## Example Usage:\n" 59 | usage += "```\n" 60 | 61 | examples.each do |example| 62 | usage += "load_guide guides: \"#{framework_name.downcase}\", guide: \"#{example[:guide]}\"#{example[:comment] ? " # " + example[:comment] : ""}\n" 63 | end 64 | 65 | usage += "```\n" 66 | usage 67 | end 68 | 69 | # Determine section from filename (handbook/reference/etc) 70 | def determine_section(filename) 71 | return "Handbook" if filename.start_with?("handbook/") 72 | return "Reference" if filename.start_with?("reference/") 73 | 74 | # Framework-specific section detection can be overridden 75 | framework_specific_section(filename) if respond_to?(:framework_specific_section, true) 76 | 77 | "Documentation" 78 | end 79 | 80 | # Format guides organized by sections (handbook/reference) 81 | def format_sectioned_guides(guide_files) 82 | sectioned = get_sectioned_guide_files(guide_files.keys.zip(guide_files.values).to_h) 83 | guides = [] 84 | 85 | # Add handbook section 86 | if sectioned[:handbook].any? 87 | guides << "\n## Handbook (Main Documentation)\n" 88 | sectioned[:handbook].each do |filename, file_data| 89 | metadata = extract_guide_metadata(filename, file_data) 90 | short_name = metadata[:guide_name].sub("handbook/", "") 91 | guides << format_guide_entry(metadata[:title], short_name, metadata[:guide_name], metadata[:description]) 92 | end 93 | end 94 | 95 | # Add reference section 96 | if sectioned[:reference].any? 97 | guides << "\n## Reference (API Documentation)\n" 98 | sectioned[:reference].each do |filename, file_data| 99 | metadata = extract_guide_metadata(filename, file_data) 100 | short_name = metadata[:guide_name].sub("reference/", "") 101 | guides << format_guide_entry(metadata[:title], short_name, metadata[:guide_name], metadata[:description]) 102 | end 103 | end 104 | 105 | # Add other sections 106 | if sectioned[:other].any? 107 | guides << "\n## Other Guides\n" 108 | sectioned[:other].each do |filename, file_data| 109 | metadata = extract_guide_metadata(filename, file_data) 110 | guides << format_guide_entry(metadata[:title], metadata[:guide_name], metadata[:guide_name], metadata[:description]) 111 | end 112 | end 113 | 114 | guides 115 | end 116 | 117 | # Format guides in a flat structure (no sections) 118 | def format_flat_guides(guide_files) 119 | guides = [] 120 | 121 | guide_files.each do |filename, file_data| 122 | log(:debug, "Processing guide: #{filename}") 123 | metadata = extract_guide_metadata(filename, file_data) 124 | guides << format_guide_entry(metadata[:title], metadata[:guide_name], metadata[:guide_name], metadata[:description]) 125 | end 126 | 127 | guides 128 | end 129 | end 130 | end 131 | -------------------------------------------------------------------------------- /lib/rails-mcp-server/resources/guide_error_handler.rb: -------------------------------------------------------------------------------- 1 | module RailsMcpServer 2 | # Module for handling guide-related errors and messages 3 | module GuideErrorHandler 4 | protected 5 | 6 | # Format error messages consistently 7 | def format_error_message(message) 8 | "# Error\n\n#{message}" 9 | end 10 | 11 | # Create standardized not found message 12 | def create_not_found_message(guide_name, available_guides) 13 | normalized_guide_name = guide_name.gsub(/[^a-zA-Z0-9_\/.-]/, "").downcase 14 | suggestions = find_suggestions(normalized_guide_name, available_guides) 15 | 16 | message = "# Guide Not Found\n\n" 17 | message += "Guide '#{guide_name}' not found in #{framework_name} guides.\n\n" 18 | 19 | if suggestions.any? 20 | message += "## Did you mean one of these?\n\n" 21 | suggestions.each { |suggestion| message += "- #{suggestion}\n" } 22 | message += "\n**Try:** `load_guide guides: \"#{framework_name.downcase}\", guide: \"#{suggestions.first}\"`\n" 23 | else 24 | message += format_available_guides_section(available_guides) 25 | message += "Use `load_guide guides: \"#{framework_name.downcase}\"` to see all available guides with descriptions.\n" 26 | end 27 | 28 | message 29 | end 30 | 31 | # Format available guides section for error messages 32 | def format_available_guides_section(available_guides) 33 | return "\n" unless supports_sections? 34 | 35 | handbook_guides = available_guides.select { |g| g.start_with?("handbook/") } 36 | reference_guides = available_guides.select { |g| g.start_with?("reference/") } 37 | 38 | message = "## Available #{framework_name} Guides:\n\n" 39 | 40 | if handbook_guides.any? 41 | message += "### Handbook:\n" 42 | handbook_guides.each { |guide| message += "- #{guide.sub("handbook/", "")}\n" } 43 | message += "\n" 44 | end 45 | 46 | if reference_guides.any? 47 | message += "### Reference:\n" 48 | reference_guides.each { |guide| message += "- #{guide.sub("reference/", "")}\n" } 49 | message += "\n" 50 | end 51 | 52 | message 53 | end 54 | 55 | # Handle manifest loading errors with user-friendly messages 56 | def handle_manifest_error(error) 57 | case error.message 58 | when /No .* guides found/ 59 | error.message 60 | when /Permission denied/ 61 | format_error_message("Permission denied accessing guides. Check file permissions.") 62 | when /No such file/ 63 | format_error_message("Guide files not found. Run '#{download_command}' to download guides.") 64 | else 65 | format_error_message("Error loading guides: #{error.message}") 66 | end 67 | end 68 | 69 | # Handle guide loading errors 70 | def handle_guide_loading_error(guide_name, error) 71 | log(:error, "Error loading guide #{guide_name}: #{error.message}") 72 | 73 | case error.message 74 | when /Multiple guides found/ 75 | format_error_message(error.message) 76 | when /Permission denied/ 77 | format_error_message("Permission denied reading guide '#{guide_name}'.") 78 | when /No such file/ 79 | format_error_message("Guide file for '#{guide_name}' not found on disk.") 80 | else 81 | format_error_message("Error loading guide '#{guide_name}': #{error.message}") 82 | end 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /lib/rails-mcp-server/resources/guide_file_finder.rb: -------------------------------------------------------------------------------- 1 | module RailsMcpServer 2 | # Module for finding and matching guide files 3 | module GuideFileFinder 4 | protected 5 | 6 | # Find guide file with exact and fuzzy matching 7 | def find_guide_file(normalized_guide_name, manifest) 8 | # Try exact matches first 9 | possible_filenames = generate_possible_filenames(normalized_guide_name) 10 | 11 | possible_filenames.each do |possible_filename| 12 | if manifest["files"][possible_filename] 13 | return [possible_filename, manifest["files"][possible_filename]] 14 | end 15 | end 16 | 17 | # If not found, try fuzzy matching 18 | matching_files = fuzzy_match_files(normalized_guide_name, manifest) 19 | 20 | case matching_files.size 21 | when 1 22 | matching_files.first 23 | when 0 24 | [nil, nil] 25 | else 26 | matches = matching_files.map(&:first).map { |f| f.sub(".md", "") }.join(", ") # rubocop:disable Performance/ChainArrayAllocation 27 | raise StandardError, "Multiple guides found matching '#{normalized_guide_name}': #{matches}. Please be more specific." 28 | end 29 | end 30 | 31 | # Generate possible filename variations for exact matching 32 | def generate_possible_filenames(normalized_guide_name) 33 | possible_files = ["#{normalized_guide_name}.md"] 34 | 35 | # Add section prefixes for frameworks that support them 36 | if supports_sections? 37 | possible_files += [ 38 | "handbook/#{normalized_guide_name}.md", 39 | "reference/#{normalized_guide_name}.md" 40 | ] 41 | end 42 | 43 | # Framework-specific filename generation can be overridden 44 | possible_files += framework_specific_filenames(normalized_guide_name) if respond_to?(:framework_specific_filenames, true) 45 | 46 | possible_files.uniq 47 | end 48 | 49 | # Perform fuzzy matching on guide files 50 | def fuzzy_match_files(normalized_guide_name, manifest) 51 | search_terms = generate_search_terms(normalized_guide_name) 52 | 53 | manifest["files"].select do |file, _| 54 | next false unless file.end_with?(".md") 55 | 56 | file_matches_any_search_term?(file, search_terms) 57 | end.to_a 58 | end 59 | 60 | # Generate search terms for fuzzy matching 61 | def generate_search_terms(normalized_guide_name) 62 | base_term = normalized_guide_name.split("/").last.downcase 63 | 64 | [ 65 | base_term, 66 | base_term.gsub(/[_-]/, ""), 67 | base_term.gsub(/[_-]/, "_"), 68 | base_term.gsub(/[_-]/, "-") 69 | ].uniq 70 | end 71 | 72 | # Check if file matches any search term 73 | def file_matches_any_search_term?(file, search_terms) 74 | file_name_base = file.sub(".md", "").split("/").last.downcase 75 | file_name_normalized = file_name_base.gsub(/[_-]/, "") 76 | 77 | search_terms.any? do |term| 78 | term_normalized = term.gsub(/[_-]/, "") 79 | 80 | file_name_base.include?(term) || 81 | term.include?(file_name_base) || 82 | file_name_normalized.include?(term_normalized) || 83 | term_normalized.include?(file_name_normalized) 84 | end 85 | end 86 | 87 | # Generate suggestions for similar guide names 88 | def find_suggestions(normalized_guide_name, available_guides) 89 | search_base = normalized_guide_name.split("/").last.downcase 90 | 91 | available_guides.select do |guide| 92 | guide_base = guide.split("/").last.downcase 93 | 94 | guide_base.include?(search_base) || 95 | search_base.include?(guide_base) || 96 | guide_base.gsub(/[_-]/, "").include?(search_base.gsub(/[_-]/, "")) 97 | end 98 | end 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /lib/rails-mcp-server/resources/guide_framework_contract.rb: -------------------------------------------------------------------------------- 1 | module RailsMcpServer 2 | # Module defining the contract that guide resources must implement 3 | module GuideFrameworkContract 4 | def self.included(base) 5 | base.extend(ClassMethods) 6 | end 7 | 8 | module ClassMethods 9 | # Validate that required methods are implemented 10 | def validate_contract! 11 | required_methods = [:framework_name, :resource_directory, :download_command] 12 | 13 | required_methods.each do |method| 14 | unless method_defined?(method) 15 | raise NotImplementedError, "#{self} must implement ##{method}" 16 | end 17 | end 18 | end 19 | end 20 | 21 | protected 22 | 23 | # Abstract methods that must be implemented by including classes 24 | def framework_name 25 | raise NotImplementedError, "#{self.class} must implement #framework_name" 26 | end 27 | 28 | def resource_directory 29 | raise NotImplementedError, "#{self.class} must implement #resource_directory" 30 | end 31 | 32 | def download_command 33 | raise NotImplementedError, "#{self.class} must implement #download_command" 34 | end 35 | 36 | # Optional methods with default implementations 37 | def supports_sections? 38 | false 39 | end 40 | 41 | # Optional method for list resources 42 | def example_guides 43 | [] 44 | end 45 | 46 | # Optional framework-specific methods (can be overridden) 47 | def framework_specific_filenames(normalized_guide_name) 48 | [] 49 | end 50 | 51 | def framework_specific_section(filename) 52 | "Documentation" 53 | end 54 | 55 | # Utility method to check if this is a list resource 56 | def list_resource? 57 | respond_to?(:example_guides) 58 | end 59 | 60 | # Utility method to check if this is a single guide resource 61 | def single_guide_resource? 62 | respond_to?(:params) && params.key?(:guide_name) 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/rails-mcp-server/resources/guide_loader_template.rb: -------------------------------------------------------------------------------- 1 | module RailsMcpServer 2 | # Template module that provides complete guide loading implementation 3 | # Eliminates the need to implement load_specific_guide in each resource 4 | module GuideLoaderTemplate 5 | def self.included(base) 6 | # Ensure all required modules are included 7 | base.include GuideFrameworkContract unless base.included_modules.include?(GuideFrameworkContract) 8 | base.include GuideManifestOperations unless base.included_modules.include?(GuideManifestOperations) 9 | base.include GuideFileFinder unless base.included_modules.include?(GuideFileFinder) 10 | base.include GuideContentFormatter unless base.included_modules.include?(GuideContentFormatter) 11 | base.include GuideErrorHandler unless base.included_modules.include?(GuideErrorHandler) 12 | end 13 | 14 | # Complete single guide content implementation 15 | def content 16 | guide_name = params[:guide_name] 17 | 18 | begin 19 | manifest = load_manifest 20 | rescue => e 21 | return handle_manifest_error(e) 22 | end 23 | 24 | if !guide_name.nil? && !guide_name.strip.empty? 25 | log(:debug, "Loading #{framework_name} guide: #{guide_name}") 26 | load_specific_guide(guide_name, manifest) 27 | else 28 | log(:debug, "Provide a name for a #{framework_name} guide") 29 | "Provide a name for a #{framework_name} guide" 30 | end 31 | end 32 | 33 | # Complete guides list implementation 34 | def list_content 35 | begin 36 | manifest = load_manifest 37 | rescue => e 38 | return handle_manifest_error(e) 39 | end 40 | 41 | log(:debug, "Loading #{framework_name} guides...") 42 | format_guides_index(manifest) 43 | end 44 | 45 | protected 46 | 47 | # Template method for loading a specific guide 48 | def load_specific_guide(guide_name, manifest) 49 | normalized_guide_name = guide_name.gsub(/[^a-zA-Z0-9_\/.-]/, "") 50 | 51 | begin 52 | filename, guide_data = find_guide_file(normalized_guide_name, manifest) 53 | 54 | if filename && guide_data 55 | guides_path = File.dirname(File.join(config_dir, "resources", resource_directory, "manifest.yaml")) 56 | guide_file_path = File.join(guides_path, filename) 57 | 58 | if File.exist?(guide_file_path) 59 | log(:debug, "Loading guide: #{filename}") 60 | content = File.read(guide_file_path) 61 | 62 | # Allow customization of display name 63 | display_name = customize_display_name(guide_name, guide_data) 64 | format_guide_content(content, display_name, guide_data, filename) 65 | else 66 | format_not_found_message(guide_name, manifest) 67 | end 68 | else 69 | format_not_found_message(guide_name, manifest) 70 | end 71 | rescue => e 72 | handle_guide_loading_error(guide_name, e) 73 | end 74 | end 75 | 76 | # Template method for formatting guides index 77 | def format_guides_index(manifest) 78 | guides = [] 79 | 80 | guides << "# Available #{framework_name} Guides\n" 81 | guides << "Use the `load_guide` tool with `guides: \"#{framework_name.downcase}\"` and `guide: \"guide_name\"` to load a specific guide.\n" 82 | 83 | if supports_sections? 84 | guides << "You can use either the full path (e.g., `handbook/01_introduction`) or just the filename (e.g., `01_introduction`).\n" 85 | end 86 | 87 | guide_files = get_guide_files(manifest) 88 | 89 | if supports_sections? 90 | guides.concat(format_sectioned_guides(guide_files)) 91 | else 92 | guides.concat(format_flat_guides(guide_files)) 93 | end 94 | 95 | # Add examples if this is a list resource 96 | if respond_to?(:example_guides) && example_guides.any? 97 | guides << format_usage_examples 98 | end 99 | 100 | guides.join("\n") 101 | end 102 | 103 | # Template method for not found messages 104 | def format_not_found_message(guide_name, manifest) 105 | guide_files = get_guide_files(manifest) 106 | available_guides = guide_files.keys.map { |f| f.sub(".md", "") } 107 | 108 | message = create_not_found_message(guide_name, available_guides) 109 | 110 | # Allow framework-specific additions to not found message 111 | message = customize_not_found_message(message, guide_name) if respond_to?(:customize_not_found_message, true) 112 | 113 | log(:error, "Guide not found: #{guide_name}") 114 | message 115 | end 116 | 117 | # Hook for customizing display name (override in resources if needed) 118 | def customize_display_name(guide_name, guide_data) 119 | guide_name 120 | end 121 | end 122 | end 123 | -------------------------------------------------------------------------------- /lib/rails-mcp-server/resources/guide_manifest_operations.rb: -------------------------------------------------------------------------------- 1 | module RailsMcpServer 2 | # Module for handling guide manifest operations 3 | module GuideManifestOperations 4 | protected 5 | 6 | # Load and validate manifest file 7 | def load_manifest 8 | manifest_file = File.join(config_dir, "resources", resource_directory, "manifest.yaml") 9 | 10 | unless File.exist?(manifest_file) 11 | error_message = "No #{framework_name} guides found. Run '#{download_command}' first." 12 | log(:error, error_message) 13 | raise StandardError, error_message 14 | end 15 | 16 | YAML.load_file(manifest_file) 17 | end 18 | 19 | # Extract guide metadata from manifest entry 20 | def extract_guide_metadata(filename, file_data) 21 | { 22 | filename: filename, 23 | guide_name: filename.sub(".md", ""), 24 | title: file_data["title"] || generate_title_from_filename(filename), 25 | description: file_data["description"] || "", 26 | original_filename: file_data["original_filename"] # For custom guides 27 | } 28 | end 29 | 30 | # Generate title from filename if not in manifest 31 | def generate_title_from_filename(filename) 32 | base_name = filename.sub(".md", "").split("/").last 33 | base_name.gsub(/[_-]/, " ").split.map(&:capitalize).join(" ") 34 | end 35 | 36 | # Get all guide files from manifest 37 | def get_guide_files(manifest) 38 | manifest["files"].select { |filename, _| filename.end_with?(".md") } 39 | end 40 | 41 | # Get guide files organized by sections 42 | def get_sectioned_guide_files(manifest) 43 | guide_files = get_guide_files(manifest) 44 | 45 | { 46 | handbook: guide_files.select { |filename, _| filename.start_with?("handbook/") }, 47 | reference: guide_files.select { |filename, _| filename.start_with?("reference/") }, 48 | other: guide_files.reject { |filename, _| filename.start_with?("handbook/", "reference/") } 49 | } 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/rails-mcp-server/resources/kamal_guides_resource.rb: -------------------------------------------------------------------------------- 1 | module RailsMcpServer 2 | class KamalGuidesResource < BaseResource 3 | include GuideLoaderTemplate 4 | 5 | uri "kamal://guides/{guide_name}" 6 | resource_name "Kamal Guides" 7 | description "Access to specific Kamal deployment documentation" 8 | mime_type "text/markdown" 9 | 10 | protected 11 | 12 | def framework_name 13 | "Kamal" 14 | end 15 | 16 | def resource_directory 17 | "kamal" 18 | end 19 | 20 | def download_command 21 | "rails-mcp-server-download-resources kamal" 22 | end 23 | 24 | # Kamal guides have subdirectories but not handbook/reference sections 25 | def supports_sections? 26 | false 27 | end 28 | 29 | # Override for Kamal's directory structure 30 | def framework_specific_filenames(normalized_guide_name) 31 | possible_files = [] 32 | 33 | if normalized_guide_name.include?("/") 34 | possible_files << normalized_guide_name 35 | possible_files << "#{normalized_guide_name}.md" 36 | else 37 | %w[installation configuration commands hooks upgrading].each do |section| 38 | possible_files << "#{section}/#{normalized_guide_name}.md" 39 | possible_files << "#{section}/index.md" if normalized_guide_name == section 40 | end 41 | possible_files << "#{normalized_guide_name}/index.md" 42 | end 43 | 44 | possible_files 45 | end 46 | 47 | # Override for Kamal's section detection 48 | def framework_specific_section(filename) 49 | case filename 50 | when /^installation\// then "Installation" 51 | when /^configuration\// then "Configuration" 52 | when /^commands\// then "Commands" 53 | when /^hooks\// then "Hooks" 54 | when /^upgrading\// then "Upgrading" 55 | else; "Documentation" 56 | end 57 | end 58 | 59 | # Enhanced fuzzy matching for hierarchical structure 60 | def fuzzy_match_files(normalized_guide_name, manifest) 61 | search_term = normalized_guide_name.downcase 62 | 63 | manifest["files"].select do |file, _| 64 | next false unless file.end_with?(".md") 65 | 66 | file_path = file.downcase 67 | file_name_base = file.sub(".md", "").split("/").last.downcase 68 | file_full_path = file.sub(".md", "").downcase 69 | 70 | file_path.include?(search_term) || 71 | file_name_base.include?(search_term) || 72 | search_term.include?(file_name_base) || 73 | file_full_path.include?(search_term) || 74 | search_term.include?(file_full_path) || 75 | file_name_base.gsub(/[_-]/, "").include?(search_term.gsub(/[_-]/, "")) || 76 | search_term.gsub(/[_-]/, "").include?(file_name_base.gsub(/[_-]/, "")) 77 | end.to_a 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /lib/rails-mcp-server/resources/kamal_guides_resources.rb: -------------------------------------------------------------------------------- 1 | module RailsMcpServer 2 | class KamalGuidesResources < BaseResource 3 | include GuideLoaderTemplate 4 | 5 | uri "kamal://guides" 6 | resource_name "Kamal Guides" 7 | description "Access to available Kamal deployment guides" 8 | mime_type "text/markdown" 9 | 10 | protected 11 | 12 | def framework_name 13 | "Kamal" 14 | end 15 | 16 | def resource_directory 17 | "kamal" 18 | end 19 | 20 | def download_command 21 | "rails-mcp-server-download-resources kamal" 22 | end 23 | 24 | def example_guides 25 | [ 26 | {guide: "installation/index", comment: "Load installation guide"}, 27 | {guide: "configuration/environment-variables", comment: "Load environment variables configuration"}, 28 | {guide: "commands/deploy", comment: "Load deploy command guide"}, 29 | {guide: "hooks/overview", comment: "Load hooks overview"}, 30 | {guide: "upgrading/overview", comment: "Load upgrading overview"} 31 | ] 32 | end 33 | 34 | # Kamal guides have subdirectories but not handbook/reference sections 35 | def supports_sections? 36 | false 37 | end 38 | 39 | # Override to format guides organized by Kamal's directory structure 40 | def format_flat_guides(manifest) 41 | guides = [] 42 | 43 | # Group guides by their directory structure 44 | sections = { 45 | "installation" => [], 46 | "configuration" => [], 47 | "commands" => [], 48 | "hooks" => [], 49 | "upgrading" => [] 50 | } 51 | 52 | other_guides = [] 53 | 54 | manifest["files"].each do |filename, file_data| 55 | next unless filename.end_with?(".md") 56 | 57 | log(:debug, "Processing guide: #{filename}") 58 | 59 | guide_name = filename.sub(".md", "") 60 | title = file_data["title"] || guide_name.split("/").last.gsub(/[_-]/, " ").split.map(&:capitalize).join(" ") 61 | description = file_data["description"] || "" 62 | 63 | # Categorize by directory 64 | case filename 65 | when /^installation\// 66 | sections["installation"] << {name: guide_name, title: title, description: description} 67 | when /^configuration\// 68 | sections["configuration"] << {name: guide_name, title: title, description: description} 69 | when /^commands\// 70 | sections["commands"] << {name: guide_name, title: title, description: description} 71 | when /^hooks\// 72 | sections["hooks"] << {name: guide_name, title: title, description: description} 73 | when /^upgrading\// 74 | sections["upgrading"] << {name: guide_name, title: title, description: description} 75 | else 76 | other_guides << {name: guide_name, title: title, description: description} 77 | end 78 | end 79 | 80 | # Format each section 81 | sections.each do |section_name, section_guides| 82 | next if section_guides.empty? 83 | 84 | guides << "\n## #{section_name.capitalize}\n" 85 | section_guides.each do |guide| 86 | guides << format_guide_entry(guide[:title], guide[:name], guide[:name], guide[:description]) 87 | end 88 | end 89 | 90 | # Add any other guides that don't fit the standard structure 91 | if other_guides.any? 92 | guides << "\n## Other\n" 93 | other_guides.each do |guide| 94 | guides << format_guide_entry(guide[:title], guide[:name], guide[:name], guide[:description]) 95 | end 96 | end 97 | 98 | guides 99 | end 100 | 101 | # Format individual guide entry 102 | def format_guide_entry(title, short_name, full_name, description) 103 | <<~GUIDE 104 | ### #{title} 105 | **Guide name:** `#{short_name}` 106 | #{description.empty? ? "" : "**Description:** #{description}"} 107 | GUIDE 108 | end 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /lib/rails-mcp-server/resources/rails_guides_resource.rb: -------------------------------------------------------------------------------- 1 | module RailsMcpServer 2 | class RailsGuidesResource < BaseResource 3 | include GuideLoaderTemplate 4 | 5 | uri "rails://guides/{guide_name}" 6 | resource_name "Rails Guides" 7 | description "Access to specific Rails documentation" 8 | mime_type "text/markdown" 9 | 10 | protected 11 | 12 | def framework_name 13 | "Rails" 14 | end 15 | 16 | def resource_directory 17 | "rails" 18 | end 19 | 20 | def download_command 21 | "rails-mcp-server-download-resources rails" 22 | end 23 | 24 | # Rails guides don't use handbook/reference sections 25 | def supports_sections? 26 | false 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/rails-mcp-server/resources/rails_guides_resources.rb: -------------------------------------------------------------------------------- 1 | module RailsMcpServer 2 | class RailsGuidesResources < BaseResource 3 | include GuideLoaderTemplate 4 | 5 | uri "rails://guides" 6 | resource_name "Rails Guides List" 7 | description "Access to available Rails guides" 8 | mime_type "text/markdown" 9 | 10 | protected 11 | 12 | def framework_name 13 | "Rails" 14 | end 15 | 16 | def resource_directory 17 | "rails" 18 | end 19 | 20 | def download_command 21 | "rails-mcp-server-download-resources rails" 22 | end 23 | 24 | def example_guides 25 | [ 26 | {guide: "active_record_validations", comment: "Load validations guide"}, 27 | {guide: "getting_started", comment: "Load getting started guide"}, 28 | {guide: "routing", comment: "Load routing guide"} 29 | ] 30 | end 31 | 32 | # Rails guides don't use handbook/reference sections 33 | def supports_sections? 34 | false 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/rails-mcp-server/resources/stimulus_guides_resource.rb: -------------------------------------------------------------------------------- 1 | module RailsMcpServer 2 | class StimulusGuidesResource < BaseResource 3 | include GuideLoaderTemplate 4 | 5 | uri "stimulus://guides/{guide_name}" 6 | resource_name "Stimulus Guides" 7 | description "Access to specific Stimulus documentation" 8 | mime_type "text/markdown" 9 | 10 | protected 11 | 12 | def framework_name 13 | "Stimulus" 14 | end 15 | 16 | def resource_directory 17 | "stimulus" 18 | end 19 | 20 | def download_command 21 | "rails-mcp-server-download-resources stimulus" 22 | end 23 | 24 | # Stimulus guides use handbook/reference sections 25 | def supports_sections? 26 | true 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/rails-mcp-server/resources/stimulus_guides_resources.rb: -------------------------------------------------------------------------------- 1 | module RailsMcpServer 2 | class StimulusGuidesResources < BaseResource 3 | include GuideLoaderTemplate 4 | 5 | uri "stimulus://guides" 6 | resource_name "Stimulus Guides" 7 | description "Access to available Stimulus guides" 8 | mime_type "text/markdown" 9 | 10 | protected 11 | 12 | def framework_name 13 | "Stimulus" 14 | end 15 | 16 | def resource_directory 17 | "stimulus" 18 | end 19 | 20 | def download_command 21 | "rails-mcp-server-download-resources stimulus" 22 | end 23 | 24 | def example_guides 25 | [ 26 | {guide: "actions", comment: "Load actions reference"}, 27 | {guide: "01_introduction", comment: "Load introduction"}, 28 | {guide: "reference/targets", comment: "Load targets with full path"} 29 | ] 30 | end 31 | 32 | # Stimulus guides use handbook/reference sections 33 | def supports_sections? 34 | true 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/rails-mcp-server/resources/turbo_guides_resource.rb: -------------------------------------------------------------------------------- 1 | module RailsMcpServer 2 | class TurboGuidesResource < BaseResource 3 | include GuideLoaderTemplate 4 | 5 | uri "turbo://guides/{guide_name}" 6 | resource_name "Turbo Guides" 7 | description "Access to specific Turbo documentation" 8 | mime_type "text/markdown" 9 | 10 | protected 11 | 12 | def framework_name 13 | "Turbo" 14 | end 15 | 16 | def resource_directory 17 | "turbo" 18 | end 19 | 20 | def download_command 21 | "rails-mcp-server-download-resources turbo" 22 | end 23 | 24 | # Turbo guides use handbook/reference sections 25 | def supports_sections? 26 | true 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/rails-mcp-server/resources/turbo_guides_resources.rb: -------------------------------------------------------------------------------- 1 | module RailsMcpServer 2 | class TurboGuidesResources < BaseResource 3 | include GuideLoaderTemplate 4 | 5 | uri "turbo://guides" 6 | resource_name "Turbo Guides" 7 | description "Access to available Turbo guides" 8 | mime_type "text/markdown" 9 | 10 | protected 11 | 12 | def framework_name 13 | "Turbo" 14 | end 15 | 16 | def resource_directory 17 | "turbo" 18 | end 19 | 20 | def download_command 21 | "rails-mcp-server-download-resources turbo" 22 | end 23 | 24 | def example_guides 25 | [ 26 | {guide: "drive", comment: "Load drive reference"}, 27 | {guide: "02_drive", comment: "Load drive handbook"}, 28 | {guide: "reference/frames", comment: "Load frames with full path"} 29 | ] 30 | end 31 | 32 | # Turbo guides use handbook/reference sections 33 | def supports_sections? 34 | true 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/rails-mcp-server/tools/analyze_controller_views.rb: -------------------------------------------------------------------------------- 1 | module RailsMcpServer 2 | class AnalyzeControllerViews < BaseTool 3 | tool_name "analyze_controller_views" 4 | 5 | description "Analyze the relationships between controllers, their actions, and corresponding views to understand the application's UI flow." 6 | 7 | arguments do 8 | optional(:controller_name).filled(:string).description("Name of a specific controller to analyze (e.g., 'UsersController' or 'users'). If omitted, all controllers will be analyzed.") 9 | end 10 | 11 | def call(controller_name: nil) 12 | unless current_project 13 | message = "No active project. Please switch to a project first." 14 | log(:warn, message) 15 | 16 | return message 17 | end 18 | 19 | # Find all controllers 20 | controllers_dir = File.join(active_project_path, "app", "controllers") 21 | unless File.directory?(controllers_dir) 22 | message = "Controllers directory not found at app/controllers." 23 | log(:warn, message) 24 | 25 | return message 26 | end 27 | 28 | # Get all controller files 29 | controller_files = Dir.glob(File.join(controllers_dir, "**", "*_controller.rb")) 30 | 31 | if controller_files.empty? 32 | message = "No controllers found in the project." 33 | log(:warn, message) 34 | 35 | return message 36 | end 37 | 38 | # If a specific controller was requested, filter the files 39 | if controller_name 40 | # Normalize controller name (allow both 'users' and 'UsersController') 41 | controller_name = "#{controller_name.sub(/_?controller$/i, "").downcase}_controller.rb" 42 | controller_files = controller_files.select { |f| File.basename(f).downcase == controller_name } 43 | 44 | if controller_files.empty? 45 | message = "Controller '#{controller_name}' not found." 46 | log(:warn, message) 47 | 48 | return message 49 | end 50 | end 51 | 52 | # Parse controllers to extract actions 53 | controllers_data = {} 54 | 55 | controller_files.each do |file_path| 56 | file_content = File.read(file_path) 57 | controller_class = File.basename(file_path, ".rb").gsub(/_controller$/i, "").then { |s| camelize(s) } + "Controller" 58 | 59 | # Extract controller actions (methods that are not private/protected) 60 | actions = [] 61 | action_matches = file_content.scan(/def\s+([a-zA-Z0-9_]+)/).flatten 62 | 63 | # Find where private/protected begins 64 | private_index = file_content =~ /^\s*(private|protected)/ 65 | 66 | if private_index 67 | # Get the actions defined before private/protected 68 | private_content = file_content[private_index..-1] 69 | private_methods = private_content.scan(/def\s+([a-zA-Z0-9_]+)/).flatten 70 | actions = action_matches - private_methods 71 | else 72 | actions = action_matches 73 | end 74 | 75 | # Remove Rails controller lifecycle methods 76 | lifecycle_methods = %w[initialize action_name controller_name params response] 77 | actions -= lifecycle_methods 78 | 79 | # Get routes mapped to this controller 80 | routes_output = RailsMcpServer::RunProcess.execute_rails_command( 81 | active_project_path, 82 | "bin/rails routes -c #{controller_class}" 83 | ) 84 | 85 | routes = {} 86 | if routes_output && !routes_output.empty? 87 | routes_output.split("\n").each do |line| 88 | next if line.include?("(erb):") || line.include?("Prefix") || line.strip.empty? 89 | parts = line.strip.split(/\s+/) 90 | if parts.size >= 4 91 | # Get action name from the rails routes output 92 | action = parts[1].to_s.strip.downcase 93 | if actions.include?(action) 94 | verb = parts[0].to_s.strip 95 | path = parts[2].to_s.strip 96 | routes[action] = {verb: verb, path: path} 97 | end 98 | end 99 | end 100 | end 101 | 102 | # Find views for each action 103 | views_dir = File.join(active_project_path, "app", "views", File.basename(file_path, "_controller.rb")) 104 | views = {} 105 | 106 | if File.directory?(views_dir) 107 | actions.each do |action| 108 | # Look for view templates with various extensions 109 | view_files = Dir.glob(File.join(views_dir, "#{action}.*")) 110 | if view_files.any? 111 | views[action] = { 112 | templates: view_files.map { |f| f.sub("#{active_project_path}/", "") }, 113 | partials: [] 114 | } 115 | 116 | # Look for partials used in this template 117 | view_files.each do |view_file| 118 | if File.file?(view_file) 119 | view_content = File.read(view_file) 120 | # Find render calls with partials 121 | partial_matches = view_content.scan(/render\s+(?:partial:|:partial\s+=>\s+|:partial\s*=>|partial:)\s*["']([^"']+)["']/).flatten 122 | views[action][:partials] += partial_matches if partial_matches.any? 123 | 124 | # Find instance variables used in the view 125 | instance_vars = view_content.scan(/@([a-zA-Z0-9_]+)/).flatten.uniq # rubocop:disable Performance/ChainArrayAllocation 126 | views[action][:instance_variables] = instance_vars if instance_vars.any? 127 | 128 | # Look for Stimulus controllers 129 | stimulus_controllers = view_content.scan(/data-controller="([^"]+)"/).flatten.uniq # rubocop:disable Performance/ChainArrayAllocation 130 | views[action][:stimulus_controllers] = stimulus_controllers if stimulus_controllers.any? 131 | end 132 | end 133 | end 134 | end 135 | end 136 | 137 | # Extract instance variables set in the controller action 138 | instance_vars_in_controller = {} 139 | actions.each do |action| 140 | # Find the action method in the controller 141 | action_match = file_content.match(/def\s+#{action}\b(.*?)(?:(?:def|private|protected|public)\b|\z)/m) 142 | if action_match && action_match[1] 143 | action_body = action_match[1] 144 | # Find instance variable assignments 145 | vars = action_body.scan(/@([a-zA-Z0-9_]+)\s*=/).flatten.uniq # rubocop:disable Performance/ChainArrayAllocation 146 | instance_vars_in_controller[action] = vars if vars.any? 147 | end 148 | end 149 | 150 | controllers_data[controller_class] = { 151 | file: file_path.sub("#{active_project_path}/", ""), 152 | actions: actions, 153 | routes: routes, 154 | views: views, 155 | instance_variables: instance_vars_in_controller 156 | } 157 | rescue => e 158 | log(:error, "Error parsing controller #{file_path}: #{e.message}") 159 | end 160 | 161 | # Format the output 162 | output = [] 163 | 164 | controllers_data.each do |controller, data| 165 | output << "Controller: #{controller}" 166 | output << " File: #{data[:file]}" 167 | output << " Actions: #{data[:actions].size}" 168 | 169 | data[:actions].each do |action| 170 | output << " Action: #{action}" 171 | 172 | # Show route if available 173 | if data[:routes] && data[:routes][action] 174 | route = data[:routes][action] 175 | output << " Route: [#{route[:verb]}] #{route[:path]}" 176 | else 177 | output << " Route: Not mapped to a route" 178 | end 179 | 180 | # Show view templates if available 181 | if data[:views] && data[:views][action] 182 | view_data = data[:views][action] 183 | 184 | output << " View Templates:" 185 | view_data[:templates].each do |template| 186 | output << " - #{template}" 187 | end 188 | 189 | # Show partials 190 | if view_data[:partials]&.any? 191 | output << " Partials Used:" 192 | view_data[:partials].uniq.each do |partial| 193 | output << " - #{partial}" 194 | end 195 | end 196 | 197 | # Show Stimulus controllers 198 | if view_data[:stimulus_controllers]&.any? 199 | output << " Stimulus Controllers:" 200 | view_data[:stimulus_controllers].each do |controller| 201 | output << " - #{controller}" 202 | end 203 | end 204 | 205 | # Show instance variables used in views 206 | if view_data[:instance_variables]&.any? 207 | output << " Instance Variables Used in View:" 208 | view_data[:instance_variables].sort.each do |var| 209 | output << " - @#{var}" 210 | end 211 | end 212 | else 213 | output << " View: No view template found" 214 | end 215 | 216 | # Show instance variables set in controller 217 | if data[:instance_variables] && data[:instance_variables][action] 218 | output << " Instance Variables Set in Controller:" 219 | data[:instance_variables][action].sort.each do |var| 220 | output << " - @#{var}" 221 | end 222 | end 223 | 224 | output << "" 225 | end 226 | 227 | output << "-------------------------" 228 | end 229 | 230 | output.join("\n") 231 | end 232 | 233 | private 234 | 235 | def camelize(string) 236 | string.split("_").map(&:capitalize).join 237 | end 238 | end 239 | end 240 | -------------------------------------------------------------------------------- /lib/rails-mcp-server/tools/analyze_environment_config.rb: -------------------------------------------------------------------------------- 1 | module RailsMcpServer 2 | class AnalyzeEnvironmentConfig < BaseTool 3 | tool_name "analyze_environment_config" 4 | 5 | description "Analyze environment configurations to identify inconsistencies, security issues, and missing variables across environments." 6 | 7 | def call 8 | unless current_project 9 | message = "No active project. Please switch to a project first." 10 | log(:warn, message) 11 | 12 | return message 13 | end 14 | 15 | # Check for required directories and files 16 | env_dir = File.join(active_project_path, "config", "environments") 17 | unless File.directory?(env_dir) 18 | message = "Environment configuration directory not found at config/environments." 19 | log(:warn, message) 20 | 21 | return message 22 | end 23 | 24 | # Initialize data structures 25 | env_files = {} 26 | env_settings = {} 27 | 28 | # 1. Parse environment files 29 | Dir.glob(File.join(env_dir, "*.rb")).each do |file| 30 | env_name = File.basename(file, ".rb") 31 | env_files[env_name] = file 32 | env_content = File.read(file) 33 | 34 | # Extract settings from environment files 35 | env_settings[env_name] = extract_env_settings(env_content) 36 | end 37 | 38 | # 2. Find ENV variable usage across the codebase 39 | env_vars_in_code = find_env_vars_in_codebase(active_project_path) 40 | 41 | # 3. Check for .env files and their variables 42 | dotenv_files = {} 43 | dotenv_vars = {} 44 | 45 | # Common .env file patterns 46 | dotenv_patterns = [ 47 | ".env", 48 | ".env.development", 49 | ".env.test", 50 | ".env.production", 51 | ".env.local", 52 | ".env.development.local", 53 | ".env.test.local", 54 | ".env.production.local" 55 | ] 56 | 57 | dotenv_patterns.each do |pattern| 58 | file_path = File.join(active_project_path, pattern) 59 | if File.exist?(file_path) 60 | dotenv_files[pattern] = file_path 61 | dotenv_vars[pattern] = parse_dotenv_file(file_path) 62 | end 63 | end 64 | 65 | # 4. Check credentials files 66 | credentials_files = {} 67 | credentials_key_file = File.join(active_project_path, "config", "master.key") 68 | credentials_file = File.join(active_project_path, "config", "credentials.yml.enc") 69 | 70 | if File.exist?(credentials_file) 71 | credentials_files["credentials.yml.enc"] = credentials_file 72 | end 73 | 74 | # Environment-specific credentials files 75 | Dir.glob(File.join(active_project_path, "config", "credentials", "*.yml.enc")).each do |file| 76 | env_name = File.basename(file, ".yml.enc") 77 | credentials_files["credentials/#{env_name}.yml.enc"] = file 78 | end 79 | 80 | # 5. Check database configuration 81 | database_config_file = File.join(active_project_path, "config", "database.yml") 82 | database_config = {} 83 | 84 | if File.exist?(database_config_file) 85 | database_config = parse_database_config(database_config_file) 86 | end 87 | 88 | # 6. Generate findings 89 | 90 | # 6.1. Compare environment settings 91 | env_diff = compare_environment_settings(env_settings) 92 | 93 | # 6.2. Find missing ENV variables 94 | missing_env_vars = find_missing_env_vars(env_vars_in_code, dotenv_vars) 95 | 96 | # 6.3. Check for potential security issues 97 | security_findings = check_security_configuration(env_settings, database_config) 98 | 99 | # Format the output 100 | output = [] 101 | 102 | # Environment files summary 103 | output << "Environment Configuration Analysis" 104 | output << "==================================" 105 | output << "" 106 | output << "Environment Files:" 107 | env_files.each do |env, file| 108 | output << " - #{env}: #{file.sub("#{active_project_path}/", "")}" 109 | end 110 | output << "" 111 | 112 | # Environment variables summary 113 | output << "Environment Variables Usage:" 114 | output << " Total unique ENV variables found in codebase: #{env_vars_in_code.keys.size}" 115 | output << "" 116 | 117 | # Missing ENV variables 118 | if missing_env_vars.any? 119 | output << "Missing ENV Variables:" 120 | missing_env_vars.each do |env_var, environments| 121 | output << " - #{env_var}: Used in codebase but missing in #{environments.join(", ")}" 122 | end 123 | else 124 | output << "All ENV variables appear to be defined in at least one .env file." 125 | end 126 | output << "" 127 | 128 | # Environment differences 129 | if env_diff[:unique_settings].any? 130 | output << "Environment-Specific Settings:" 131 | env_diff[:unique_settings].each do |env, settings| 132 | output << " #{env}:" 133 | settings.each do |setting| 134 | output << " - #{setting}" 135 | end 136 | end 137 | output << "" 138 | end 139 | 140 | if env_diff[:different_values].any? 141 | output << "Settings with Different Values Across Environments:" 142 | env_diff[:different_values].each do |setting, values| 143 | output << " #{setting}:" 144 | values.each do |env, value| 145 | output << " - #{env}: #{value}" 146 | end 147 | end 148 | output << "" 149 | end 150 | 151 | # Credentials files 152 | output << "Credentials Management:" 153 | if credentials_files.any? 154 | output << " Encrypted credentials files found:" 155 | credentials_files.each do |name, file| 156 | output << " - #{name}" 157 | end 158 | 159 | output << if File.exist?(credentials_key_file) 160 | " Master key file exists (config/master.key)" 161 | else 162 | " Warning: No master.key file found. Credentials are likely managed through RAILS_MASTER_KEY environment variable." 163 | end 164 | else 165 | output << " No encrypted credentials files found. The application may be using ENV variables exclusively." 166 | end 167 | output << "" 168 | 169 | # Database configuration 170 | output << "Database Configuration:" 171 | if database_config.any? 172 | database_config.each do |env, config| 173 | output << " #{env}:" 174 | # Show connection details without exposing passwords 175 | if config["adapter"] 176 | output << " - Adapter: #{config["adapter"]}" 177 | end 178 | if config["host"] && config["host"] != "localhost" && config["host"] != "127.0.0.1" 179 | output << " - Host: #{config["host"]}" 180 | end 181 | if config["database"] 182 | output << " - Database: #{config["database"]}" 183 | end 184 | 185 | # Check for credentials in database.yml 186 | if config["username"] && !config["username"].include?("ENV") 187 | output << " - Warning: Database username hardcoded in database.yml" 188 | end 189 | if config["password"] && !config["password"].include?("ENV") 190 | output << " - Warning: Database password hardcoded in database.yml" 191 | end 192 | end 193 | else 194 | output << " Could not parse database configuration." 195 | end 196 | output << "" 197 | 198 | # Security findings 199 | if security_findings.any? 200 | output << "Security Configuration Findings:" 201 | security_findings.each do |finding| 202 | output << " - #{finding}" 203 | end 204 | output << "" 205 | end 206 | 207 | output.join("\n") 208 | end 209 | 210 | private 211 | 212 | # Helper method to extract settings from environment files 213 | def extract_env_settings(content) 214 | settings = {} 215 | 216 | # Match configuration settings 217 | content.scan(/config\.([a-zA-Z0-9_.]+)\s*=\s*([^#\n]+)/) do |match| 218 | key = match[0].strip 219 | value = match[1].strip 220 | 221 | # Clean up the value 222 | value = value.chomp(";").strip 223 | 224 | settings[key] = value 225 | end 226 | 227 | settings 228 | end 229 | 230 | # Helper method to find ENV variable usage in the codebase 231 | def find_env_vars_in_codebase(project_path) 232 | env_vars = {} 233 | 234 | # Define directories to search 235 | search_dirs = [ 236 | File.join(project_path, "app"), 237 | File.join(project_path, "config"), 238 | File.join(project_path, "lib") 239 | ] 240 | 241 | # Define file patterns to search 242 | file_patterns = ["*.rb", "*.yml", "*.erb", "*.js"] 243 | 244 | search_dirs.each do |dir| 245 | if File.directory?(dir) 246 | file_patterns.each do |pattern| 247 | Dir.glob(File.join(dir, "**", pattern)).each do |file| 248 | content = File.read(file) 249 | 250 | # Extract ENV variables 251 | content.scan(/ENV\s*\[\s*['"]([^'"]+)['"]\s*\]/).each do |match| 252 | env_var = match[0] 253 | env_vars[env_var] ||= [] 254 | env_vars[env_var] << file.sub("#{project_path}/", "") 255 | end 256 | 257 | # Also match ENV['VAR'] pattern 258 | content.scan(/ENV\s*\.\s*\[\s*['"]([^'"]+)['"]\s*\]/).each do |match| 259 | env_var = match[0] 260 | env_vars[env_var] ||= [] 261 | env_vars[env_var] << file.sub("#{project_path}/", "") 262 | end 263 | 264 | # Also match ENV.fetch('VAR') pattern 265 | content.scan(/ENV\s*\.\s*fetch\s*\(\s*['"]([^'"]+)['"]\s*/).each do |match| 266 | env_var = match[0] 267 | env_vars[env_var] ||= [] 268 | env_vars[env_var] << file.sub("#{project_path}/", "") 269 | end 270 | rescue => e 271 | log(:error, "Error reading file #{file}: #{e.message}") 272 | end 273 | end 274 | end 275 | end 276 | 277 | env_vars 278 | end 279 | 280 | # Helper method to parse .env files 281 | def parse_dotenv_file(file_path) 282 | vars = {} 283 | 284 | begin 285 | File.readlines(file_path).each do |line| # rubocop:disable Performance/IoReadlines 286 | # Skip comments and empty lines 287 | next if line.strip.empty? || line.strip.start_with?("#") 288 | 289 | # Parse KEY=value pattern 290 | if line =~ /\A([A-Za-z0-9_]+)=(.*)\z/ 291 | key = $1 292 | # Store just the existence of the variable, not its value 293 | vars[key] = true 294 | end 295 | end 296 | rescue => e 297 | log(:error, "Error parsing .env file #{file_path}: #{e.message}") 298 | end 299 | 300 | vars 301 | end 302 | 303 | # Helper method to parse database.yml 304 | def parse_database_config(file_path) 305 | config = {} 306 | 307 | begin 308 | # Simple YAML parsing - not handling ERB 309 | yaml_content = File.read(file_path) 310 | yaml_data = YAML.safe_load(yaml_content) || {} 311 | 312 | # Extract environment configurations 313 | %w[development test production staging].each do |env| 314 | config[env] = yaml_data[env] if yaml_data[env] 315 | end 316 | rescue => e 317 | log(:error, "Error parsing database.yml: #{e.message}") 318 | end 319 | 320 | config 321 | end 322 | 323 | # Helper method to compare environment settings 324 | def compare_environment_settings(env_settings) 325 | result = { 326 | unique_settings: {}, 327 | different_values: {} 328 | } 329 | 330 | # Get all settings across all environments 331 | all_settings = env_settings.values.map(&:keys).flatten.uniq # rubocop:disable Performance/ChainArrayAllocation 332 | 333 | # Find settings unique to certain environments 334 | env_settings.each do |env, settings| 335 | unique = settings.keys - (all_settings - settings.keys) 336 | result[:unique_settings][env] = unique if unique.any? 337 | end 338 | 339 | # Find settings with different values across environments 340 | all_settings.each do |setting| 341 | values = {} 342 | 343 | env_settings.each do |env, settings| 344 | values[env] = settings[setting] if settings[setting] 345 | end 346 | 347 | # Only include if there are different values 348 | if values.values.uniq.size > 1 349 | result[:different_values][setting] = values 350 | end 351 | end 352 | 353 | result 354 | end 355 | 356 | # Helper method to find missing ENV variables 357 | def find_missing_env_vars(env_vars_in_code, dotenv_vars) 358 | missing_vars = {} 359 | 360 | # Check each ENV variable used in code 361 | env_vars_in_code.each do |var, files| 362 | # Environments where the variable is missing 363 | missing_in = [] 364 | 365 | # Check in each .env file 366 | if dotenv_vars.empty? 367 | missing_in << "all environments (no .env files found)" 368 | else 369 | dotenv_vars.each do |env_file, vars| 370 | env_name = env_file.gsub(/^\.env\.?|\.local$/, "") 371 | env_name = "development" if env_name.empty? 372 | 373 | if !vars.key?(var) 374 | missing_in << env_name 375 | end 376 | end 377 | end 378 | 379 | missing_vars[var] = missing_in if missing_in.any? 380 | end 381 | 382 | missing_vars 383 | end 384 | 385 | # Helper method to check for security issues 386 | def check_security_configuration(env_settings, database_config) 387 | findings = [] 388 | 389 | # Check for common security settings 390 | env_settings.each do |env, settings| 391 | # Check for secure cookies in production 392 | if env == "production" 393 | if settings["cookies.secure"] == "false" 394 | findings << "Production has cookies.secure = false" 395 | end 396 | 397 | if settings["session_store.secure"] == "false" 398 | findings << "Production has session_store.secure = false" 399 | end 400 | 401 | # Force SSL 402 | if settings["force_ssl"] == "false" 403 | findings << "Production has force_ssl = false" 404 | end 405 | end 406 | 407 | # Check for CSRF protection 408 | if settings["action_controller.default_protect_from_forgery"] == "false" 409 | findings << "#{env} has CSRF protection disabled" 410 | end 411 | end 412 | 413 | # Check for hardcoded credentials in database.yml 414 | database_config.each do |env, config| 415 | if config["username"] && !config["username"].include?("ENV") 416 | findings << "Database username hardcoded in database.yml for #{env}" 417 | end 418 | 419 | if config["password"] && !config["password"].include?("ENV") 420 | findings << "Database password hardcoded in database.yml for #{env}" 421 | end 422 | end 423 | 424 | findings 425 | end 426 | end 427 | end 428 | -------------------------------------------------------------------------------- /lib/rails-mcp-server/tools/analyze_models.rb: -------------------------------------------------------------------------------- 1 | module RailsMcpServer 2 | class AnalyzeModels < BaseTool 3 | tool_name "analyze_models" 4 | 5 | description "Retrieve detailed information about Active Record models in the project. When called without parameters, lists all model files. When a specific model is specified, returns its schema, associations (has_many, belongs_to, has_one), and complete source code." 6 | 7 | arguments do 8 | optional(:model_name).filled(:string).description("Class name of a specific model to get detailed information for (e.g., 'User', 'Product'). Use CamelCase, not snake_case. If omitted, returns a list of all models.") 9 | end 10 | 11 | def call(model_name: nil) 12 | unless current_project 13 | message = "No active project. Please switch to a project first." 14 | log(:warn, message) 15 | 16 | return message 17 | end 18 | 19 | if model_name 20 | log(:info, "Getting info for specific model: #{model_name}") 21 | 22 | # Check if the model file exists 23 | model_file = File.join(active_project_path, "app", "models", "#{underscore(model_name)}.rb") 24 | unless File.exist?(model_file) 25 | log(:warn, "Model file not found: #{model_name}") 26 | message = "Model '#{model_name}' not found." 27 | log(:warn, message) 28 | 29 | return message 30 | end 31 | 32 | log(:debug, "Reading model file: #{model_file}") 33 | 34 | # Get the model file content 35 | model_content = File.read(model_file) 36 | 37 | # Try to get schema information 38 | log(:debug, "Executing Rails runner to get schema information") 39 | schema_info = execute_rails_command( 40 | active_project_path, 41 | "runner \"puts #{model_name}.column_names\"" 42 | ) 43 | 44 | # Try to get associations 45 | associations = [] 46 | if model_content.include?("has_many") 47 | has_many = model_content.scan(/has_many\s+:(\w+)/).flatten 48 | associations << "Has many: #{has_many.join(", ")}" unless has_many.empty? 49 | end 50 | 51 | if model_content.include?("belongs_to") 52 | belongs_to = model_content.scan(/belongs_to\s+:(\w+)/).flatten 53 | associations << "Belongs to: #{belongs_to.join(", ")}" unless belongs_to.empty? 54 | end 55 | 56 | if model_content.include?("has_one") 57 | has_one = model_content.scan(/has_one\s+:(\w+)/).flatten 58 | associations << "Has one: #{has_one.join(", ")}" unless has_one.empty? 59 | end 60 | 61 | log(:debug, "Found #{associations.size} associations for model: #{model_name}") 62 | 63 | # Format the output 64 | <<~INFO 65 | Model: #{model_name} 66 | 67 | Schema: 68 | #{schema_info} 69 | 70 | Associations: 71 | #{associations.empty? ? "None found" : associations.join("\n")} 72 | 73 | Model Definition: 74 | ```ruby 75 | #{model_content} 76 | ``` 77 | INFO 78 | else 79 | log(:info, "Listing all models") 80 | 81 | # List all models 82 | models_dir = File.join(active_project_path, "app", "models") 83 | unless File.directory?(models_dir) 84 | message = "Models directory not found." 85 | log(:warn, message) 86 | 87 | return message 88 | end 89 | 90 | # Get all .rb files in the models directory and its subdirectories 91 | model_files = Dir.glob(File.join(models_dir, "**", "*.rb")) 92 | .map { |f| f.sub("#{models_dir}/", "").sub(/\.rb$/, "") } 93 | .sort # rubocop:disable Performance/ChainArrayAllocation 94 | 95 | log(:debug, "Found #{model_files.size} model files") 96 | 97 | "Models in the project:\n\n#{model_files.join("\n")}" 98 | end 99 | end 100 | 101 | private 102 | 103 | def execute_rails_command(project_path, command) 104 | full_command = "cd #{project_path} && bin/rails #{command}" 105 | `#{full_command}` 106 | end 107 | 108 | def underscore(string) 109 | string.gsub("::", "/") 110 | .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2') 111 | .gsub(/([a-z\d])([A-Z])/, '\1_\2') 112 | .tr("-", "_") 113 | .downcase 114 | end 115 | end 116 | end 117 | -------------------------------------------------------------------------------- /lib/rails-mcp-server/tools/base_tool.rb: -------------------------------------------------------------------------------- 1 | module RailsMcpServer 2 | class BaseTool < FastMcp::Tool 3 | extend Forwardable 4 | 5 | def_delegators :RailsMcpServer, :log, :projects 6 | def_delegators :RailsMcpServer, :current_project=, :current_project 7 | def_delegators :RailsMcpServer, :active_project_path=, :active_project_path 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/rails-mcp-server/tools/get_file.rb: -------------------------------------------------------------------------------- 1 | module RailsMcpServer 2 | class GetFile < BaseTool 3 | tool_name "get_file" 4 | 5 | description "Retrieve the complete content of a specific file with syntax highlighting. Use this to examine implementation details, configurations, or any text file in the project." 6 | 7 | arguments do 8 | required(:path).filled(:string).description("File path relative to the project root (e.g., 'app/models/user.rb', 'config/routes.rb'). Use list_files first if you're not sure about the exact path.") 9 | end 10 | 11 | def call(path:) 12 | unless current_project 13 | message = "No active project. Please switch to a project first." 14 | log(:warn, message) 15 | 16 | return message 17 | end 18 | 19 | full_path = File.join(active_project_path, path) 20 | 21 | unless File.exist?(full_path) 22 | message = "File '#{path}' not found in the project." 23 | log(:warn, message) 24 | 25 | return message 26 | end 27 | 28 | content = File.read(full_path) 29 | log(:debug, "Read file: #{path} (#{content.size} bytes)") 30 | 31 | "File: #{path}\n\n```#{get_file_extension(path)}\n#{content}\n```" 32 | end 33 | 34 | private 35 | 36 | def get_file_extension(path) 37 | case File.extname(path).downcase 38 | when ".rb" 39 | "ruby" 40 | when ".js" 41 | "javascript" 42 | when ".html", ".erb" 43 | "html" 44 | when ".css" 45 | "css" 46 | when ".json" 47 | "json" 48 | when ".yml", ".yaml" 49 | "yaml" 50 | else 51 | "" 52 | end 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/rails-mcp-server/tools/get_model.rb: -------------------------------------------------------------------------------- 1 | module RailsMcpServer 2 | class GetModels < BaseTool 3 | tool_name "get_models" 4 | 5 | description "Retrieve detailed information about Active Record models in the project. When called without parameters, lists all model files. When a specific model is specified, returns its schema, associations (has_many, belongs_to, has_one), and complete source code." 6 | 7 | arguments do 8 | optional(:model_name).filled(:string).description("Class name of a specific model to get detailed information for (e.g., 'User', 'Product'). Use CamelCase, not snake_case. If omitted, returns a list of all models.") 9 | end 10 | 11 | def call(model_name: nil) 12 | unless current_project 13 | message = "No active project. Please switch to a project first." 14 | log(:warn, message) 15 | 16 | return message 17 | end 18 | 19 | if model_name 20 | log(:info, "Getting info for specific model: #{model_name}") 21 | 22 | # Check if the model file exists 23 | model_file = File.join(active_project_path, "app", "models", "#{underscore(model_name)}.rb") 24 | unless File.exist?(model_file) 25 | log(:warn, "Model file not found: #{model_name}") 26 | message = "Model '#{model_name}' not found." 27 | log(:warn, message) 28 | 29 | return message 30 | end 31 | 32 | log(:debug, "Reading model file: #{model_file}") 33 | 34 | # Get the model file content 35 | model_content = File.read(model_file) 36 | 37 | # Try to get schema information 38 | log(:debug, "Executing Rails runner to get schema information") 39 | schema_info = execute_rails_command( 40 | active_project_path, 41 | "runner \"puts #{model_name}.column_names\"" 42 | ) 43 | 44 | # Try to get associations 45 | associations = [] 46 | if model_content.include?("has_many") 47 | has_many = model_content.scan(/has_many\s+:(\w+)/).flatten 48 | associations << "Has many: #{has_many.join(", ")}" unless has_many.empty? 49 | end 50 | 51 | if model_content.include?("belongs_to") 52 | belongs_to = model_content.scan(/belongs_to\s+:(\w+)/).flatten 53 | associations << "Belongs to: #{belongs_to.join(", ")}" unless belongs_to.empty? 54 | end 55 | 56 | if model_content.include?("has_one") 57 | has_one = model_content.scan(/has_one\s+:(\w+)/).flatten 58 | associations << "Has one: #{has_one.join(", ")}" unless has_one.empty? 59 | end 60 | 61 | log(:debug, "Found #{associations.size} associations for model: #{model_name}") 62 | 63 | # Format the output 64 | <<~INFO 65 | Model: #{model_name} 66 | 67 | Schema: 68 | #{schema_info} 69 | 70 | Associations: 71 | #{associations.empty? ? "None found" : associations.join("\n")} 72 | 73 | Model Definition: 74 | ```ruby 75 | #{model_content} 76 | ``` 77 | INFO 78 | else 79 | log(:info, "Listing all models") 80 | 81 | # List all models 82 | models_dir = File.join(active_project_path, "app", "models") 83 | unless File.directory?(models_dir) 84 | message = "Models directory not found." 85 | log(:warn, message) 86 | 87 | return message 88 | end 89 | 90 | # Get all .rb files in the models directory and its subdirectories 91 | model_files = Dir.glob(File.join(models_dir, "**", "*.rb")) 92 | .map { |f| f.sub("#{models_dir}/", "").sub(/\.rb$/, "") } 93 | .sort # rubocop:disable Performance/ChainArrayAllocation 94 | 95 | log(:debug, "Found #{model_files.size} model files") 96 | 97 | "Models in the project:\n\n#{model_files.join("\n")}" 98 | end 99 | end 100 | 101 | private 102 | 103 | def execute_rails_command(project_path, command) 104 | full_command = "cd #{project_path} && bin/rails #{command}" 105 | `#{full_command}` 106 | end 107 | 108 | def underscore(string) 109 | string.gsub("::", "/") 110 | .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2') 111 | .gsub(/([a-z\d])([A-Z])/, '\1_\2') 112 | .tr("-", "_") 113 | .downcase 114 | end 115 | end 116 | end 117 | -------------------------------------------------------------------------------- /lib/rails-mcp-server/tools/get_routes.rb: -------------------------------------------------------------------------------- 1 | module RailsMcpServer 2 | class GetRoutes < BaseTool 3 | tool_name "get_routes" 4 | 5 | description "Retrieve all HTTP routes defined in the Rails application with their associated controllers and actions. Equivalent to running 'rails routes' command. This helps understand the API endpoints or page URLs available in the application." 6 | 7 | def call 8 | unless current_project 9 | message = "No active project. Please switch to a project first." 10 | log(:warn, message) 11 | 12 | return message 13 | end 14 | 15 | # Execute the Rails routes command 16 | routes_output = RailsMcpServer::RunProcess.execute_rails_command( 17 | active_project_path, "bin/rails routes" 18 | ) 19 | log(:debug, "Routes command completed, output size: #{routes_output.size} bytes") 20 | 21 | "Rails Routes:\n\n```\n#{routes_output}\n```" 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/rails-mcp-server/tools/get_schema.rb: -------------------------------------------------------------------------------- 1 | module RailsMcpServer 2 | class GetSchema < BaseTool 3 | tool_name "get_schema" 4 | 5 | description "Retrieve database schema information for the Rails application. Without parameters, returns all tables and the complete schema.rb. With a table name, returns detailed column information including data types, constraints, and foreign keys for that specific table." 6 | 7 | arguments do 8 | optional(:table_name).filled(:string).description("Database table name to get detailed schema information for (e.g., 'users', 'products'). Use snake_case, plural form. If omitted, returns complete database schema.") 9 | end 10 | 11 | def call(table_name: nil) 12 | unless current_project 13 | message = "No active project. Please switch to a project first." 14 | log(:warn, message) 15 | 16 | return message 17 | end 18 | 19 | if table_name 20 | log(:info, "Getting schema for table: #{table_name}") 21 | 22 | # Execute the Rails schema command for a specific table 23 | schema_output = RailsMcpServer::RunProcess.execute_rails_command( 24 | active_project_path, 25 | "bin/rails runner \"require 'active_record'; puts ActiveRecord::Base.connection.columns('#{table_name}').map{|c| [c.name, c.type, c.null, c.default].inspect}.join('\\n')\"" 26 | ) 27 | 28 | if schema_output.strip.empty? 29 | message = "Table '#{table_name}' not found or has no columns." 30 | log(:warn, message) 31 | 32 | return message 33 | end 34 | 35 | # Parse the column information 36 | columns = schema_output.strip.split("\\n").map do |column_info| 37 | eval(column_info) # This is safe because we're generating the string ourselves # rubocop:disable Security/Eval 38 | end 39 | 40 | # Format the output 41 | formatted_columns = columns.map do |name, type, nullable, default| 42 | "#{name} (#{type})#{nullable ? ", nullable" : ""}#{default ? ", default: #{default}" : ""}" 43 | end 44 | 45 | output = <<~SCHEMA 46 | Table: #{table_name} 47 | 48 | Columns: 49 | #{formatted_columns.join("\n")} 50 | SCHEMA 51 | 52 | # Try to get foreign keys 53 | begin 54 | fk_output = RailsMcpServer::RunProcess.execute_rails_command( 55 | active_project_path, 56 | "bin/rails runner \"require 'active_record'; puts ActiveRecord::Base.connection.foreign_keys('#{table_name}').map{|fk| [fk.from_table, fk.to_table, fk.column, fk.primary_key].inspect}.join('\n')\"" 57 | ) 58 | 59 | unless fk_output.strip.empty? 60 | foreign_keys = fk_output.strip.split("\n").map do |fk_info| 61 | eval(fk_info) # This is safe because we're generating the string ourselves # rubocop:disable Security/Eval 62 | end 63 | 64 | formatted_fks = foreign_keys.map do |from_table, to_table, column, primary_key| 65 | "#{column} -> #{to_table}.#{primary_key}" 66 | end 67 | 68 | output += <<~FK 69 | 70 | Foreign Keys: 71 | #{formatted_fks.join("\n")} 72 | FK 73 | end 74 | rescue => e 75 | log(:warn, "Error fetching foreign keys: #{e.message}") 76 | end 77 | 78 | output 79 | else 80 | log(:info, "Getting full schema") 81 | 82 | # Execute the Rails schema:dump command 83 | # First, check if we need to create the schema file 84 | schema_file = File.join(active_project_path, "db", "schema.rb") 85 | unless File.exist?(schema_file) 86 | log(:info, "Schema file not found, attempting to generate it") 87 | RailsMcpServer::RunProcess.execute_rails_command(active_project_path, "db:schema:dump") 88 | end 89 | 90 | if File.exist?(schema_file) 91 | # Read the schema file 92 | schema_content = File.read(schema_file) 93 | 94 | # Try to get table list 95 | tables_output = RailsMcpServer::RunProcess.execute_rails_command( 96 | active_project_path, 97 | "bin/rails runner \"require 'active_record'; puts ActiveRecord::Base.connection.tables.sort.join('\n')\"" 98 | ) 99 | 100 | tables = tables_output.strip.split("\n") 101 | 102 | <<~SCHEMA 103 | Database Schema 104 | 105 | Tables: 106 | #{tables.join("\n")} 107 | 108 | Schema Definition: 109 | ```ruby 110 | #{schema_content} 111 | ``` 112 | SCHEMA 113 | else 114 | # If we can't get the schema file, try to get the table list 115 | tables_output = RailsMcpServer::RunProcess.execute_rails_command( 116 | active_project_path, 117 | "bin/rails runner \"require 'active_record'; puts ActiveRecord::Base.connection.tables.sort.join('\n')\"" 118 | ) 119 | 120 | if tables_output.strip.empty? 121 | message = "Could not retrieve schema information. Try running 'rails db:schema:dump' in your project first." 122 | log(:warn, message) 123 | 124 | return message 125 | end 126 | 127 | tables = tables_output.strip.split("\n") 128 | 129 | <<~SCHEMA 130 | Database Schema 131 | 132 | Tables: 133 | #{tables.join("\n")} 134 | 135 | Note: Full schema definition is not available. Run 'rails db:schema:dump' to generate the schema.rb file. 136 | SCHEMA 137 | end 138 | end 139 | end 140 | end 141 | end 142 | -------------------------------------------------------------------------------- /lib/rails-mcp-server/tools/list_files.rb: -------------------------------------------------------------------------------- 1 | module RailsMcpServer 2 | class ListFiles < BaseTool 3 | tool_name "list_files" 4 | 5 | description "List files in the Rails project matching specific criteria. Use this to explore project directories or locate specific file types. If no parameters are provided, lists files in the project root." 6 | 7 | arguments do 8 | optional(:directory).filled(:string).description("Directory path relative to the project root (e.g., 'app/models', 'config'). Leave empty to list files at the root.") 9 | optional(:pattern).filled(:string).description("File pattern using glob syntax (e.g., '*.rb' for Ruby files, '*.erb' for ERB templates, '*_controller.rb' for controllers)") 10 | end 11 | 12 | def call(directory: "", pattern: "*.rb") 13 | unless current_project 14 | message = "No active project. Please switch to a project first." 15 | log(:warn, message) 16 | 17 | return message 18 | end 19 | 20 | full_path = File.join(active_project_path, directory) 21 | unless File.directory?(full_path) 22 | message = "Directory '#{directory}' not found in the project." 23 | log(:warn, message) 24 | 25 | return message 26 | end 27 | 28 | # Check if this is a git repository 29 | is_git_repo = system("cd #{active_project_path} && git rev-parse --is-inside-work-tree > /dev/null 2>&1") 30 | 31 | if is_git_repo 32 | log(:debug, "Project is a git repository, using git ls-files") 33 | 34 | # Use git ls-files for tracked files 35 | relative_dir = directory.empty? ? "" : "#{directory}/" 36 | git_cmd = "cd #{active_project_path} && git ls-files --cached --others --exclude-standard #{relative_dir}#{pattern}" 37 | 38 | files = `#{git_cmd}`.split("\n").map(&:strip).sort # rubocop:disable Performance/ChainArrayAllocation 39 | else 40 | log(:debug, "Project is not a git repository or git not available, using Dir.glob") 41 | 42 | # Use Dir.glob as fallback 43 | files = Dir.glob(File.join(full_path, pattern)) 44 | .map { |f| f.sub("#{active_project_path}/", "") } 45 | .reject { |file| file.start_with?(".git/", ".ruby-lsp/", "node_modules/", "storage/", "public/assets/", "public/packs/", ".bundle/", "vendor/bundle/", "vendor/cache/", "tmp/", "log/") } # rubocop:disable Performance/ChainArrayAllocation 46 | .sort # rubocop:disable Performance/ChainArrayAllocation 47 | end 48 | 49 | log(:debug, "Found #{files.size} files matching pattern (respecting .gitignore and ignoring node_modules)") 50 | 51 | "Files in #{directory.empty? ? "project root" : directory} matching '#{pattern}':\n\n#{files.join("\n")}" 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/rails-mcp-server/tools/load_guide.rb: -------------------------------------------------------------------------------- 1 | module RailsMcpServer 2 | class LoadGuide < BaseTool 3 | tool_name "load_guide" 4 | 5 | description "Load documentation guides from Rails, Turbo, Stimulus, Kamal, or Custom. Use this to get guide content for context in conversations." 6 | 7 | arguments do 8 | required(:guides).filled(:string).description("The guides library to search: 'rails', 'turbo', 'stimulus', 'kamal', or 'custom'") 9 | optional(:guide).maybe(:string).description("Specific guide name to load. If not provided, returns available guides list.") 10 | end 11 | 12 | def call(guides:, guide: nil) 13 | # Normalize guides parameter 14 | guides_type = guides.downcase.strip 15 | 16 | # Validate supported guide types 17 | unless %w[rails turbo stimulus kamal custom].include?(guides_type) 18 | message = "Unsupported guide type '#{guides_type}'. Supported types: rails, turbo, stimulus, kamal, custom." 19 | log(:error, message) 20 | return message 21 | end 22 | 23 | if guide.nil? || guide.strip.empty? 24 | log(:debug, "Loading available #{guides_type} guides...") 25 | load_guides_list(guides_type) 26 | else 27 | log(:debug, "Loading specific #{guides_type} guide: #{guide}") 28 | load_specific_guide(guide, guides_type) 29 | end 30 | end 31 | 32 | private 33 | 34 | def load_guides_list(guides_type) 35 | case guides_type 36 | when "rails" 37 | uri = "rails://guides" 38 | read_resource(uri, RailsGuidesResources) 39 | when "stimulus" 40 | uri = "stimulus://guides" 41 | read_resource(uri, StimulusGuidesResources) 42 | when "turbo" 43 | uri = "turbo://guides" 44 | read_resource(uri, TurboGuidesResources) 45 | when "kamal" 46 | uri = "kamal://guides" 47 | read_resource(uri, KamalGuidesResources) 48 | when "custom" 49 | uri = "custom://guides" 50 | read_resource(uri, CustomGuidesResources) 51 | else 52 | "Guide type '#{guides_type}' not supported." 53 | end 54 | end 55 | 56 | def load_specific_guide(guide_name, guides_type) 57 | # First try exact match 58 | exact_match_content = try_exact_match(guide_name, guides_type) 59 | return exact_match_content if exact_match_content && !exact_match_content.include?("Guide not found") 60 | 61 | # If exact match fails, try fuzzy matching 62 | try_fuzzy_matching(guide_name, guides_type) 63 | end 64 | 65 | def try_exact_match(guide_name, guides_type) 66 | case guides_type 67 | when "rails" 68 | uri = "rails://guides/#{guide_name}" 69 | read_resource(uri, RailsGuidesResource, {guide_name: guide_name}) 70 | when "stimulus" 71 | uri = "stimulus://guides/#{guide_name}" 72 | read_resource(uri, StimulusGuidesResource, {guide_name: guide_name}) 73 | when "turbo" 74 | uri = "turbo://guides/#{guide_name}" 75 | read_resource(uri, TurboGuidesResource, {guide_name: guide_name}) 76 | when "kamal" 77 | uri = "kamal://guides/#{guide_name}" 78 | read_resource(uri, KamalGuidesResource, {guide_name: guide_name}) 79 | when "custom" 80 | uri = "custom://guides/#{guide_name}" 81 | read_resource(uri, CustomGuidesResource, {guide_name: guide_name}) 82 | else 83 | "Guide type '#{guides_type}' not supported." 84 | end 85 | end 86 | 87 | def try_fuzzy_matching(guide_name, guides_type) 88 | # Get all matching guides using the base guide resource directly 89 | matching_guides = find_matching_guides(guide_name, guides_type) 90 | 91 | case matching_guides.size 92 | when 0 93 | format_guide_not_found_message(guide_name, guides_type) 94 | when 1 95 | # Load the single match 96 | match = matching_guides.first 97 | log(:debug, "Found single fuzzy match: #{match}") 98 | try_exact_match(match, guides_type) 99 | when 2..3 100 | # Load multiple matches (up to 3) 101 | log(:debug, "Found #{matching_guides.size} fuzzy matches, loading all") 102 | load_multiple_guides(matching_guides, guides_type, guide_name) 103 | else 104 | # Too many matches, show options 105 | format_multiple_matches_message(guide_name, matching_guides, guides_type) 106 | end 107 | end 108 | 109 | def find_matching_guides(guide_name, guides_type) 110 | # Get the manifest to find matching files 111 | manifest = load_manifest_for_guides_type(guides_type) 112 | return [] unless manifest 113 | 114 | available_guides = manifest["files"].keys.select { |f| f.end_with?(".md") }.map { |f| f.sub(".md", "") } # rubocop:disable Performance/ChainArrayAllocation 115 | 116 | # Generate variations and find matches 117 | variations = generate_guide_name_variations(guide_name, guides_type) 118 | matching_guides = [] 119 | 120 | variations.each do |variation| 121 | matches = available_guides.select do |guide| 122 | guide.downcase.include?(variation.downcase) || 123 | variation.downcase.include?(guide.downcase) || 124 | guide.gsub(/[_\-\s]/, "").downcase.include?(variation.gsub(/[_\-\s]/, "").downcase) 125 | end 126 | matching_guides.concat(matches) 127 | end 128 | 129 | matching_guides.uniq.sort # rubocop:disable Performance/ChainArrayAllocation 130 | end 131 | 132 | def load_manifest_for_guides_type(guides_type) 133 | config = RailsMcpServer.config 134 | manifest_file = File.join(config.config_dir, "resources", guides_type, "manifest.yaml") 135 | 136 | return nil unless File.exist?(manifest_file) 137 | 138 | YAML.load_file(manifest_file) 139 | rescue => e 140 | log(:error, "Failed to load manifest for #{guides_type}: #{e.message}") 141 | nil 142 | end 143 | 144 | def load_multiple_guides(guide_names, guides_type, original_query) 145 | results = [] 146 | 147 | results << "# Multiple Guides Found for '#{original_query}'" 148 | results << "" 149 | results << "Found #{guide_names.size} matching guides. Loading all:\n" 150 | 151 | guide_names.each_with_index do |guide_name, index| 152 | results << "---" 153 | results << "" 154 | results << "## #{index + 1}. #{guide_name}" 155 | results << "" 156 | 157 | content = try_exact_match(guide_name, guides_type) 158 | if content && !content.include?("Guide not found") && !content.include?("Error") 159 | # Remove the header from individual guide content to avoid duplication 160 | clean_content = content.sub(/^#[^\n]*\n/, "").sub(/^\*\*Source:.*?\n---\n/m, "") 161 | results << clean_content.strip 162 | else 163 | results << "*Failed to load this guide*" 164 | end 165 | 166 | results << "" if index < guide_names.size - 1 167 | end 168 | 169 | results.join("\n") 170 | end 171 | 172 | def format_multiple_matches_message(guide_name, matches, guides_type) 173 | message = <<~MSG 174 | # Multiple Guides Found 175 | 176 | Found #{matches.size} guides matching '#{guide_name}' in #{guides_type} guides: 177 | 178 | MSG 179 | 180 | matches.first(10).each_with_index do |match, index| 181 | message += "#{index + 1}. #{match}\n" 182 | end 183 | 184 | if matches.size > 10 185 | message += "... and #{matches.size - 10} more\n" 186 | end 187 | 188 | message += <<~MSG 189 | 190 | ## To load a specific guide, use the exact name: 191 | ``` 192 | MSG 193 | 194 | matches.first(3).each do |match| 195 | message += "load_guide guides: \"#{guides_type}\", guide: \"#{match}\"\n" 196 | end 197 | 198 | message += "```\n" 199 | message 200 | end 201 | 202 | def read_resource(uri, resource_class, params = {}) 203 | # Check if the resource supports the instance method (from templating extension) 204 | if resource_class.respond_to?(:instance) 205 | instance = resource_class.instance(uri) 206 | return instance.content 207 | end 208 | 209 | # Fallback: manually create instance with proper initialization 210 | create_resource_instance(resource_class, params) 211 | rescue => e 212 | log(:error, "Error reading resource #{uri}: #{e.message}") 213 | format_error_message("Error loading guide: #{e.message}") 214 | end 215 | 216 | def create_resource_instance(resource_class, params) 217 | # Create instance using the proper pattern for FastMcp resources 218 | instance = resource_class.allocate 219 | 220 | # Set up the instance with parameters 221 | instance.instance_variable_set(:@params, params) 222 | 223 | # Initialize the instance (this calls the BaseResource initialize) 224 | instance.send(:initialize) 225 | 226 | # Call content to get the actual guide content 227 | instance.content 228 | end 229 | 230 | def generate_guide_name_variations(guide_name, guides_type) 231 | variations = [] 232 | 233 | # Original name 234 | variations << guide_name 235 | 236 | # Underscore variations 237 | variations << guide_name.gsub(/[_-]/, "_") 238 | variations << guide_name.gsub(/\s+/, "_") 239 | 240 | # Hyphen variations 241 | variations << guide_name.gsub(/[_-]/, "-") 242 | variations << guide_name.gsub(/\s+/, "-") 243 | 244 | # Case variations 245 | variations << guide_name.downcase 246 | variations << guide_name.upcase 247 | 248 | # Remove special characters 249 | variations << guide_name.gsub(/[^a-zA-Z0-9_\/.-]/, "") 250 | 251 | # Common guide patterns (snake_case, kebab-case) 252 | if !guide_name.include?("_") 253 | variations << guide_name.gsub(/([a-z])([A-Z])/, '\1_\2').downcase 254 | end 255 | 256 | # For Stimulus/Turbo, try with handbook/ and reference/ prefixes 257 | # Custom and Rails and Kamal guides use flat structure, so no prefixes needed 258 | unless guide_name.include?("/") || %w[custom rails kamal].include?(guides_type) 259 | variations << "handbook/#{guide_name}" 260 | variations << "reference/#{guide_name}" 261 | end 262 | 263 | # Remove path prefixes for alternatives (for Stimulus/Turbo) 264 | if guide_name.include?("/") && !%w[custom rails kamal].include?(guides_type) 265 | base_name = guide_name.split("/").last 266 | variations << base_name 267 | variations.concat(generate_guide_name_variations(base_name, guides_type)) 268 | end 269 | 270 | variations.uniq.compact # rubocop:disable Performance/ChainArrayAllocation 271 | end 272 | 273 | def format_guide_not_found_message(guide_name, guides_type) 274 | message = <<~MSG 275 | # Guide Not Found 276 | 277 | Guide '#{guide_name}' not found in #{guides_type} guides. 278 | 279 | ## Suggestions: 280 | - Use `load_guide guides: "#{guides_type}"` to see all available guides 281 | - Check the guide name spelling 282 | - Try common variations like: 283 | - `#{guide_name.gsub(/[_-]/, "_")}` 284 | - `#{guide_name.gsub(/\s+/, "_")}` 285 | - `#{guide_name.downcase}` 286 | MSG 287 | 288 | # Add framework-specific suggestions 289 | case guides_type 290 | when "stimulus", "turbo" 291 | message += <<~MSG 292 | - Try with section prefix: `handbook/#{guide_name}` or `reference/#{guide_name}` 293 | - Try without section prefix if you used one 294 | MSG 295 | when "custom" 296 | message += <<~MSG 297 | - Import custom guides with: `rails-mcp-server-download-resources --file /path/to/guides` 298 | - Make sure your custom guides have been imported 299 | MSG 300 | when "kamal" 301 | message += <<~MSG 302 | - Try with section prefix: `commands/#{guide_name}` or `configuration/#{guide_name}` 303 | - Check available sections: installation, configuration, commands, hooks, upgrading 304 | MSG 305 | end 306 | 307 | message += <<~MSG 308 | 309 | ## Available Commands: 310 | - List guides: `load_guide guides: "#{guides_type}"` 311 | - Load guide: `load_guide guides: "#{guides_type}", guide: "guide_name"` 312 | 313 | ## Example Usage: 314 | ``` 315 | MSG 316 | 317 | case guides_type 318 | when "rails" 319 | message += <<~MSG 320 | load_guide guides: "rails", guide: "active_record_validations" 321 | load_guide guides: "rails", guide: "getting_started" 322 | MSG 323 | when "stimulus" 324 | message += <<~MSG 325 | load_guide guides: "stimulus", guide: "actions" 326 | load_guide guides: "stimulus", guide: "01_introduction" 327 | load_guide guides: "stimulus", guide: "handbook/02_hello_stimulus" 328 | MSG 329 | when "turbo" 330 | message += <<~MSG 331 | load_guide guides: "turbo", guide: "drive" 332 | load_guide guides: "turbo", guide: "02_drive" 333 | load_guide guides: "turbo", guide: "reference/attributes" 334 | MSG 335 | when "kamal" 336 | message += <<~MSG 337 | load_guide guides: "kamal", guide: "installation" 338 | load_guide guides: "kamal", guide: "configuration" 339 | load_guide guides: "kamal", guide: "commands/deploy" 340 | MSG 341 | when "custom" 342 | message += <<~MSG 343 | load_guide guides: "custom", guide: "api_documentation" 344 | load_guide guides: "custom", guide: "setup_guide" 345 | load_guide guides: "custom", guide: "user_manual" 346 | MSG 347 | end 348 | 349 | message += "```\n" 350 | 351 | log(:warn, "Guide not found: #{guide_name}") 352 | message 353 | end 354 | 355 | def format_error_message(message) 356 | <<~MSG 357 | # Error Loading Guide 358 | 359 | #{message} 360 | 361 | ## Troubleshooting: 362 | - Ensure guides are downloaded: `rails-mcp-server-download-resources [rails|stimulus|turbo|kamal]` 363 | - For custom guides: `rails-mcp-server-download-resources --file /path/to/guides` 364 | - Check that the MCP server is properly configured 365 | - Verify guide name is correct 366 | - Use `load_guide guides: "[rails|stimulus|turbo|kamal|custom]"` to see available guides 367 | MSG 368 | end 369 | end 370 | end 371 | -------------------------------------------------------------------------------- /lib/rails-mcp-server/tools/project_info.rb: -------------------------------------------------------------------------------- 1 | module RailsMcpServer 2 | class ProjectInfo < BaseTool 3 | tool_name "project_info" 4 | 5 | description "Retrieve comprehensive information about the current Rails project, including Rails version, directory structure, API-only status, and overall project organization. Useful for initial project exploration and understanding the codebase structure." 6 | 7 | def call 8 | unless current_project 9 | message = "No active project. Please switch to a project first." 10 | log(:warn, message) 11 | 12 | return message 13 | end 14 | 15 | # Get additional project information 16 | gemfile_path = File.join(active_project_path, "Gemfile") 17 | gemfile_content = File.exist?(gemfile_path) ? File.read(gemfile_path) : "Gemfile not found" 18 | 19 | # Get Rails version 20 | rails_version = gemfile_content.match(/gem ['"]rails['"],\s*['"](.+?)['"]/)&.captures&.first || "Unknown" 21 | 22 | # Check if it's an API-only app 23 | config_application_path = File.join(active_project_path, "config", "application.rb") 24 | is_api_only = File.exist?(config_application_path) && 25 | File.read(config_application_path).include?("config.api_only = true") 26 | 27 | log(:info, "Project info: Rails v#{rails_version}, API-only: #{is_api_only}") 28 | 29 | <<~INFO 30 | Current project: #{current_project} 31 | Path: #{active_project_path} 32 | Rails version: #{rails_version} 33 | API only: #{is_api_only ? "Yes" : "No"} 34 | 35 | Project structure: 36 | #{get_directory_structure(active_project_path, max_depth: 2)} 37 | INFO 38 | end 39 | 40 | private 41 | 42 | # Utility functions for Rails operations 43 | def get_directory_structure(path, max_depth: 3, current_depth: 0, prefix: "") 44 | return "" if current_depth > max_depth || !File.directory?(path) 45 | 46 | # Define ignored directories 47 | ignored_dirs = [ 48 | ".git", "node_modules", "tmp", "log", 49 | "storage", "coverage", "public/assets", 50 | "public/packs", ".bundle", "vendor/bundle", 51 | "vendor/cache", ".ruby-lsp" 52 | ] 53 | 54 | output = "" 55 | directories = [] 56 | files = [] 57 | 58 | Dir.foreach(path) do |entry| 59 | next if entry == "." || entry == ".." 60 | next if ignored_dirs.include?(entry) # Skip ignored directories 61 | 62 | full_path = File.join(path, entry) 63 | 64 | if File.directory?(full_path) 65 | directories << entry 66 | else 67 | files << entry 68 | end 69 | end 70 | 71 | directories.sort.each do |dir| 72 | output << "#{prefix}└── #{dir}/\n" 73 | full_path = File.join(path, dir) 74 | output << get_directory_structure(full_path, max_depth: max_depth, 75 | current_depth: current_depth + 1, 76 | prefix: "#{prefix} ") 77 | end 78 | 79 | files.sort.each do |file| 80 | output << "#{prefix}└── #{file}\n" 81 | end 82 | 83 | output 84 | end 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /lib/rails-mcp-server/tools/switch_project.rb: -------------------------------------------------------------------------------- 1 | module RailsMcpServer 2 | class SwitchProject < BaseTool 3 | tool_name "switch_project" 4 | 5 | description "Change the active Rails project to interact with a different codebase. Must be called before using other tools. Available projects are defined in the projects.yml configuration file." 6 | 7 | arguments do 8 | required(:project_name).filled(:string).description("Name of the project as defined in the projects.yml file (case-sensitive)") 9 | end 10 | 11 | def call(project_name:) 12 | if projects.key?(project_name) 13 | self.current_project = project_name 14 | self.active_project_path = File.expand_path(projects[project_name]) 15 | log(:info, "Switched to project: #{project_name} at path: #{active_project_path}") 16 | 17 | "Switched to project: #{project_name} at path: #{active_project_path}" 18 | else 19 | log(:warn, "Project not found: #{project_name}") 20 | 21 | "Project '#{project_name}' not found. Available projects: #{projects.keys.join(", ")}" 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/rails-mcp-server/utilities/run_process.rb: -------------------------------------------------------------------------------- 1 | require "bundler" 2 | 3 | module RailsMcpServer 4 | class RunProcess 5 | def self.execute_rails_command(project_path, command) 6 | subprocess_env = ENV.to_h.merge(Bundler.original_env).merge( 7 | "BUNDLE_GEMFILE" => File.join(project_path, "Gemfile") 8 | ) 9 | 10 | RailsMcpServer.log(:debug, "Executing: #{command}") 11 | 12 | # Execute the command and capture stdout, stderr, and status 13 | stdout_str, stderr_str, status = Open3.capture3(subprocess_env, command, chdir: project_path) 14 | 15 | if status.success? 16 | RailsMcpServer.log(:debug, "Command succeeded") 17 | stdout_str 18 | else 19 | # Log error details 20 | RailsMcpServer.log(:error, "Command failed with status: #{status.exitstatus}") 21 | RailsMcpServer.log(:error, "stderr: #{stderr_str}") 22 | 23 | # Return error message 24 | "Error executing Rails command: #{command}\n\n#{stderr_str}" 25 | end 26 | rescue => e 27 | RailsMcpServer.log(:error, "Exception executing Rails command: #{e.message}") 28 | "Exception executing command: #{e.message}" 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/rails-mcp-server/version.rb: -------------------------------------------------------------------------------- 1 | module RailsMcpServer 2 | VERSION = "1.2.1" 3 | end 4 | -------------------------------------------------------------------------------- /lib/rails_mcp_server.rb: -------------------------------------------------------------------------------- 1 | require "logger" 2 | require "fileutils" 3 | require "forwardable" 4 | require "open3" 5 | require_relative "rails-mcp-server/version" 6 | require_relative "rails-mcp-server/config" 7 | require_relative "rails-mcp-server/extensions/resource_templating" 8 | require_relative "rails-mcp-server/extensions/server_templating" 9 | require_relative "rails-mcp-server/utilities/run_process" 10 | require_relative "rails-mcp-server/tools/base_tool" 11 | require_relative "rails-mcp-server/tools/project_info" 12 | require_relative "rails-mcp-server/tools/list_files" 13 | require_relative "rails-mcp-server/tools/get_file" 14 | require_relative "rails-mcp-server/tools/get_routes" 15 | require_relative "rails-mcp-server/tools/analyze_models" 16 | require_relative "rails-mcp-server/tools/get_schema" 17 | require_relative "rails-mcp-server/tools/analyze_controller_views" 18 | require_relative "rails-mcp-server/tools/analyze_environment_config" 19 | require_relative "rails-mcp-server/tools/switch_project" 20 | require_relative "rails-mcp-server/tools/load_guide" 21 | require_relative "rails-mcp-server/resources/base_resource" 22 | 23 | require_relative "rails-mcp-server/resources/guide_content_formatter" 24 | require_relative "rails-mcp-server/resources/guide_error_handler" 25 | require_relative "rails-mcp-server/resources/guide_file_finder" 26 | require_relative "rails-mcp-server/resources/guide_loader_template" 27 | require_relative "rails-mcp-server/resources/guide_manifest_operations" 28 | require_relative "rails-mcp-server/resources/guide_framework_contract" 29 | 30 | require_relative "rails-mcp-server/resources/rails_guides_resource" 31 | require_relative "rails-mcp-server/resources/rails_guides_resources" 32 | require_relative "rails-mcp-server/resources/stimulus_guides_resource" 33 | require_relative "rails-mcp-server/resources/stimulus_guides_resources" 34 | require_relative "rails-mcp-server/resources/turbo_guides_resource" 35 | require_relative "rails-mcp-server/resources/turbo_guides_resources" 36 | require_relative "rails-mcp-server/resources/custom_guides_resource" 37 | require_relative "rails-mcp-server/resources/custom_guides_resources" 38 | require_relative "rails-mcp-server/resources/kamal_guides_resource" 39 | require_relative "rails-mcp-server/resources/kamal_guides_resources" 40 | 41 | module RailsMcpServer 42 | LEVELS = {debug: Logger::DEBUG, info: Logger::INFO, error: Logger::ERROR} 43 | @config = Config.setup 44 | 45 | class << self 46 | extend Forwardable 47 | 48 | attr_reader :config 49 | 50 | def_delegators :@config, :log_level, :log_level= 51 | def_delegators :@config, :logger, :logger= 52 | def_delegators :@config, :projects 53 | def_delegators :@config, :current_project, :current_project= 54 | def_delegators :@config, :active_project_path, :active_project_path= 55 | def_delegators :@config, :config_dir 56 | 57 | def log(level, message) 58 | log_level = LEVELS[level] || Logger::INFO 59 | 60 | @config.logger.add(log_level, message) 61 | end 62 | 63 | # NOTE: This needs to be removed once FastMcp provides official support for URI templating 64 | # Setup method to initialize FastMcp::Resource extensions 65 | # Call this after the gem is loaded to enable URI templating 66 | def setup_resource_extensions! 67 | Extensions::ResourceExtensionSetup.setup! 68 | end 69 | 70 | # Check if resource extensions are loaded 71 | def resource_extensions_loaded? 72 | Extensions::ResourceExtensionSetup.setup_complete? 73 | end 74 | 75 | # Setup method to initialize FastMcp::Server extensions 76 | # This is called automatically by resource extension setup 77 | def setup_server_extensions! 78 | Extensions::ServerExtensionSetup.setup! 79 | end 80 | 81 | # Check if server extensions are loaded 82 | def server_extensions_loaded? 83 | Extensions::ServerExtensionSetup.setup_complete? 84 | end 85 | 86 | # Setup all extensions at once 87 | def setup_extensions! 88 | setup_resource_extensions! 89 | # Server extensions are setup automatically by resource extensions 90 | end 91 | 92 | # Check if all extensions are loaded 93 | def extensions_loaded? 94 | resource_extensions_loaded? && server_extensions_loaded? 95 | end 96 | end 97 | 98 | class Error < StandardError; end 99 | 100 | # Auto-setup extensions when the module is loaded 101 | # This ensures extensions are available immediately 102 | setup_resource_extensions! 103 | end 104 | -------------------------------------------------------------------------------- /rails-mcp-server.gemspec: -------------------------------------------------------------------------------- 1 | require_relative "lib/rails-mcp-server/version" 2 | 3 | Gem::Specification.new do |spec| 4 | spec.name = "rails-mcp-server" 5 | spec.version = RailsMcpServer::VERSION 6 | spec.authors = ["Mario Alberto Chávez Cárdenas"] 7 | spec.email = ["mario.chavez@gmail.com"] 8 | 9 | spec.summary = "MCP server for Rails projects" 10 | spec.description = "A Ruby implementation of Model Context Protocol server for Rails projects" 11 | spec.homepage = "https://github.com/maquina-app/rails-mcp-server" 12 | spec.license = "MIT" 13 | spec.required_ruby_version = Gem::Requirement.new(">= 2.5.0") 14 | 15 | spec.metadata["allowed_push_host"] = "https://rubygems.org" 16 | spec.metadata["homepage_uri"] = spec.homepage 17 | spec.metadata["source_code_uri"] = spec.homepage 18 | spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md" 19 | 20 | spec.files = Dir.glob("{lib,exe,config,docs}/**/*") + %w[LICENSE.txt README.md CHANGELOG.md] 21 | spec.bindir = "exe" 22 | spec.executables = ["rails-mcp-server", "rails-mcp-setup-claude", "rails-mcp-server-download-resources"] 23 | spec.require_paths = ["lib"] 24 | 25 | spec.add_dependency "addressable", "~> 2.8" 26 | spec.add_dependency "fast-mcp", "~> 1.4.0" 27 | spec.add_dependency "rack", "~> 3.1.12" 28 | spec.add_dependency "puma", "~> 6.6.0" 29 | spec.add_dependency "logger", "~> 1.7.0" 30 | spec.add_development_dependency "standard" 31 | end 32 | --------------------------------------------------------------------------------