tidewave 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: ee211dd3b35cb2ea32392547b47a98b0f8e990396b2a8380b84ed276e1d31bc2
4
+ data.tar.gz: 2c93855bf42fc23fc66385b1c9c5cf0ff58279cc63a3bc15b3b85bf291529817
5
+ SHA512:
6
+ metadata.gz: 7b2d3559b9e38fc4b8f619502d41d90aa23cbdae59446f8d53d84e22be0eb41a6ae8e39223e5ccd0a37ee4a50086c2dbdd460b4dfb4ef8236677d430d6e8f3ff
7
+ data.tar.gz: a306c3e8fa81bf5b7794d5789aa62c5169eba6a60e36f1e68105d1740e9c5bbe464558c4911b04cb5a6c7560cacb532c2312d4f588192341ed458401d6e345f4
data/README.md ADDED
@@ -0,0 +1,61 @@
1
+ # Tidewave
2
+
3
+ Tidewave speeds up development with an AI assistant that understands your web application,
4
+ how it runs, and what it delivers. Our current release connects your editor's
5
+ assistant to your web framework runtime via [MCP](https://modelcontextprotocol.io/).
6
+
7
+ [See our website](https://tidewave.ai) for more information.
8
+
9
+ ## Key Features
10
+
11
+ Tidewave provides tools that allow your LLM of choice to:
12
+
13
+ - inspect your application logs to help debugging errors
14
+ - execute SQL queries and inspect your database
15
+ - evaluate custom Ruby code in the context of your project
16
+ - find Rubygems packages and source code locations
17
+
18
+ and more.
19
+
20
+ ## Installation
21
+
22
+ You can install Tidewave by adding the `tidewave` gem to the development group in your Gemfile:
23
+
24
+ ```ruby
25
+ gem "tidewave", group: :development
26
+ ```
27
+
28
+ Tidewave will now run on the same port as your regular Rails application.
29
+ In particular, the MCP is located by default at http://localhost:3000/tidewave/mcp.
30
+ [You must configure your editor and AI assistants accordingly](https://hexdocs.pm/tidewave/mcp.html).
31
+
32
+ ## Considerations
33
+
34
+ ### Production Environment
35
+
36
+ Tidewave is a powerful tool that can help you develop your web application faster and more efficiently.
37
+ However, it is important to note that Tidewave is not meant to be used in a production environment.
38
+
39
+ Tidewave will raise an error if it is used in a production environment.
40
+
41
+ ### Web server requirements
42
+
43
+ Tidewave currently requires a threaded web server like Puma.
44
+
45
+ ## Acknowledgements
46
+
47
+ A thank you to Yorick Jacquin, for creating [FastMCP](https://github.com/yjacquin/fast_mcp) and implementing the initial version of this project.
48
+
49
+ ## License
50
+
51
+ Copyright (c) 2025 Dashbit
52
+
53
+ Licensed under the Apache License, Version 2.0 (the "License");
54
+ you may not use this file except in compliance with the License.
55
+ You may obtain a copy of the License at [http://www.apache.org/licenses/LICENSE-2.0](http://www.apache.org/licenses/LICENSE-2.0)
56
+
57
+ Unless required by applicable law or agreed to in writing, software
58
+ distributed under the License is distributed on an "AS IS" BASIS,
59
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
60
+ See the License for the specific language governing permissions and
61
+ limitations under the License.
data/Rakefile ADDED
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+
5
+ load "rails/tasks/engine.rake"
6
+
7
+ load "rails/tasks/statistics.rake"
8
+
9
+ require "bundler/gem_tasks"
@@ -0,0 +1,3 @@
1
+ test:
2
+ adapter: sqlite3
3
+ database: ":memory:"
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tidewave
4
+ module FileTracker
5
+ extend self
6
+
7
+ def project_files
8
+ `git --git-dir #{git_root}/.git ls-files --cached --others --exclude-standard`.split("\n")
9
+ end
10
+
11
+ def read_file(path)
12
+ validate_path_access!(path)
13
+
14
+ # Retrieve the full path
15
+ full_path = file_full_path(path)
16
+
17
+ # Record the file as read
18
+ record_read(path)
19
+
20
+ # Read and return the file contents
21
+ File.read(full_path)
22
+ end
23
+
24
+ def write_file(path, content)
25
+ validate_path_access!(path, validate_existence: false)
26
+ # Retrieve the full path
27
+ full_path = file_full_path(path)
28
+
29
+ dirname = File.dirname(full_path)
30
+
31
+ # Create the directory if it doesn't exist
32
+ FileUtils.mkdir_p(dirname)
33
+
34
+ # Write the file contents
35
+ File.write(full_path, content)
36
+
37
+ # Read and return the file contents
38
+ read_file(path)
39
+ end
40
+
41
+ def file_full_path(path)
42
+ File.join(git_root, path)
43
+ end
44
+
45
+ def git_root
46
+ @git_root ||= `git rev-parse --show-toplevel`.strip
47
+ end
48
+
49
+ def validate_path_access!(path, validate_existence: true)
50
+ raise ArgumentError, "File path must not start with '..'" if path.start_with?("..")
51
+
52
+ # Ensure the path is within the project
53
+ full_path = file_full_path(path)
54
+
55
+ # Verify the file is within the project directory
56
+ raise ArgumentError, "File path must be within the project directory" unless full_path.start_with?(git_root)
57
+
58
+ # Verify the file exists
59
+ raise ArgumentError, "File not found: #{path}" unless File.exist?(full_path) && validate_existence
60
+
61
+ true
62
+ end
63
+
64
+ def validate_path_is_editable!(path)
65
+ validate_path_access!(path)
66
+ validate_path_has_been_read_since_last_write!(path)
67
+
68
+ true
69
+ end
70
+
71
+ def validate_path_is_writable!(path)
72
+ validate_path_access!(path, validate_existence: false)
73
+ validate_path_has_been_read_since_last_write!(path)
74
+
75
+ true
76
+ end
77
+
78
+ def validate_path_has_been_read_since_last_write!(path)
79
+ raise ArgumentError, "File has been modified since last read, please read the file again" unless file_was_read_since_last_write?(path)
80
+
81
+ true
82
+ end
83
+
84
+ # Record when a file was read
85
+ def record_read(path)
86
+ file_records[path] = Time.now
87
+ end
88
+
89
+
90
+ def file_was_read_since_last_write?(path)
91
+ file_was_read?(path) && last_read_at(path) >= last_modified_at(path)
92
+ end
93
+
94
+ # Check if a file has been read
95
+ def file_was_read?(path)
96
+ file_records.key?(path)
97
+ end
98
+
99
+ # Check if a file exists
100
+ def file_exists?(path)
101
+ File.exist?(file_full_path(path))
102
+ end
103
+
104
+ # Get the timestamp when a file was last read
105
+ def last_read_at(path)
106
+ file_records[path]
107
+ end
108
+
109
+ def last_modified_at(path)
110
+ File.mtime(file_full_path(path))
111
+ end
112
+
113
+ # Reset all tracked files (useful for testing)
114
+ def reset
115
+ @file_records = {}
116
+ end
117
+
118
+ # Hash mapping file paths to their read records
119
+ def file_records
120
+ @file_records ||= {}
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fast_mcp"
4
+ require "logger"
5
+ require "fileutils"
6
+ require "tidewave/tool_resolver"
7
+
8
+ module Tidewave
9
+ class Railtie < Rails::Railtie
10
+ initializer "tidewave.setup_mcp" do |app|
11
+ # Prevent MCP server from being mounted if Rails is not running in development mode
12
+ raise "For security reasons, Tidewave is only supported in development mode" unless Rails.env.development?
13
+
14
+ # Set up MCP server with the host application
15
+ FastMcp.mount_in_rails(
16
+ app,
17
+ name: "tidewave",
18
+ version: Tidewave::VERSION,
19
+ path_prefix: Tidewave::PATH_PREFIX,
20
+ messages_route: Tidewave::MESSAGES_ROUTE,
21
+ sse_route: Tidewave::SSE_ROUTE,
22
+ logger: Logger.new(STDOUT)
23
+ ) do |server|
24
+ app.config.before_initialize do
25
+ # Register a custom middleware to register tools depending on `include_fs_tools` query parameter
26
+ server.register_tools(*Tidewave::ToolResolver::ALL_TOOLS)
27
+ app.middleware.use Tidewave::ToolResolver, server
28
+ end
29
+ end
30
+ end
31
+
32
+ # Install generator to set up necessary files in the host application
33
+ generators do
34
+ require "generators/tidewave/install/install_generator"
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack"
4
+
5
+ gem_tools_path = File.expand_path("../../lib/tidewave/tools/**/*.rb", __dir__)
6
+ Dir[gem_tools_path].each { |f| require f }
7
+
8
+ module Tidewave
9
+ class ToolResolver
10
+ ALL_TOOLS = Tidewave::Tools::Base.descendants
11
+ NON_FILE_SYSTEM_TOOLS = ALL_TOOLS.reject(&:file_system_tool?)
12
+ MESSAGES_PATH = "/tidewave/messages".freeze
13
+ TOOLS_LIST_METHOD = "tools/list".freeze
14
+ INCLUDE_FS_TOOLS_PARAM = "include_fs_tools".freeze
15
+
16
+ def initialize(app, server)
17
+ @app = app
18
+ @server = server
19
+ end
20
+
21
+ def call(env)
22
+ request = Rack::Request.new(env)
23
+ request_path = request.path
24
+ request_body = extract_request_body(request)
25
+ request_params = request.params
26
+
27
+ # Override tools list response if requested
28
+ return override_tools_list_response(env) if overriding_tools_list_request?(request_path, request_params, request_body)
29
+
30
+ # Forward the request to the underlying app (RackTransport)
31
+ @app.call(env)
32
+ end
33
+
34
+ private
35
+
36
+ def extract_request_body(request)
37
+ JSON.parse(request.body.read)
38
+ rescue JSON::ParserError => e
39
+ {}
40
+ ensure
41
+ request.body.rewind
42
+ end
43
+
44
+ # When we want to exclude file system tools, we need to handle the request differently to prevent from listing them
45
+ def overriding_tools_list_request?(request_path, request_params, request_body)
46
+ request_path == MESSAGES_PATH && request_body["method"] == TOOLS_LIST_METHOD && request_params[INCLUDE_FS_TOOLS_PARAM] != "true"
47
+ end
48
+
49
+ RESPONSE_HEADERS = { "Content-Type" => "application/json" }
50
+
51
+ def override_tools_list_response(env)
52
+ register_non_file_system_tools
53
+ @app.call(env).tap { register_all_tools }
54
+ end
55
+
56
+ def register_non_file_system_tools
57
+ reset_server_tools
58
+ @server.register_tools(*NON_FILE_SYSTEM_TOOLS)
59
+ end
60
+
61
+ def register_all_tools
62
+ reset_server_tools
63
+ @server.register_tools(*ALL_TOOLS)
64
+ end
65
+
66
+ def reset_server_tools
67
+ @server.instance_variable_set(:@tools, {})
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tidewave
4
+ module Tools
5
+ class Base < FastMcp::Tool
6
+ def self.file_system_tool
7
+ @file_system_tool = true
8
+ end
9
+
10
+ def self.file_system_tool?
11
+ @file_system_tool
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tidewave/file_tracker"
4
+
5
+ # TODO: This tool is not yet implemented, it does not track the latest file read.
6
+ # Also, it does not raise an error if the file contains the substring multiple times.
7
+ class Tidewave::Tools::EditProjectFile < Tidewave::Tools::Base
8
+ file_system_tool
9
+
10
+ tool_name "edit_project_file"
11
+ description <<~DESCRIPTION
12
+ A tool for editing parts of a file. It can find and replace text inside a file.
13
+ For moving or deleting files, use the shell_eval tool with 'mv' or 'rm' instead.
14
+
15
+ For large edits, use the write_project_file tool instead and overwrite the entire file.
16
+
17
+ Before editing, ensure to read the source file using the read_project_file tool.
18
+
19
+ To use this tool, provide the path to the file, the old_string to search for, and the new_string to replace it with.
20
+ If the old_string is found multiple times, an error will be returned. To ensure uniqueness, include a couple of lines
21
+ before and after the edit. All whitespace must be preserved as in the original file.
22
+
23
+ This tool can only do a single edit at a time. If you need to make multiple edits, you can create a message with
24
+ multiple tool calls to this tool, ensuring that each one contains enough context to uniquely identify the edit.
25
+ DESCRIPTION
26
+
27
+ arguments do
28
+ required(:path).filled(:string).description("The path to the file to edit. It is relative to the project root.")
29
+ required(:old_string).filled(:string).description("The string to search for")
30
+ required(:new_string).filled(:string).description("The string to replace the old_string with")
31
+ end
32
+
33
+ def call(path:, old_string:, new_string:)
34
+ # Check if the file exists within the project root and has been read
35
+ Tidewave::FileTracker.validate_path_is_editable!(path)
36
+
37
+ old_content = Tidewave::FileTracker.read_file(path)
38
+
39
+ # Ensure old_string is unique within the file
40
+ scan_result = old_content.scan(old_string)
41
+ raise ArgumentError, "old_string is not found" if scan_result.empty?
42
+ raise ArgumentError, "old_string is not unique" if scan_result.size > 1
43
+
44
+ new_content = old_content.sub(old_string, new_string)
45
+
46
+ Tidewave::FileTracker.write_file(path, new_content)
47
+ end
48
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Tidewave::Tools::ExecuteSqlQuery < Tidewave::Tools::Base
4
+ tool_name "execute_sql_query"
5
+ description <<~DESCRIPTION
6
+ Executes the given SQL query against the ActiveRecord database connection.
7
+ Returns the result as a Ruby data structure.
8
+
9
+ Note that the output is limited to 50 rows at a time. If you need to see more, perform additional calls
10
+ using LIMIT and OFFSET in the query. If you know that only specific columns are relevant,
11
+ only include those in the SELECT clause.
12
+
13
+ You can use this tool to select user data, manipulate entries, and introspect the application data domain.
14
+ Always ensure to use the correct SQL commands for the database you are using.
15
+
16
+ For PostgreSQL, use $1, $2, etc. for parameter placeholders.
17
+ For MySQL, use ? for parameter placeholders.
18
+ DESCRIPTION
19
+
20
+ arguments do
21
+ required(:query).filled(:string).description("The SQL query to execute. For PostgreSQL, use $1, $2 placeholders. For MySQL, use ? placeholders.")
22
+ optional(:arguments).value(:array).description("The arguments to pass to the query. The query must contain corresponding parameter placeholders.")
23
+ end
24
+
25
+ RESULT_LIMIT = 50
26
+
27
+ def call(query:, arguments: [])
28
+ # Get the ActiveRecord connection
29
+ conn = ActiveRecord::Base.connection
30
+
31
+ # Execute the query with prepared statement and arguments
32
+ if arguments.any?
33
+ result = conn.exec_query(query, "SQL", arguments)
34
+ else
35
+ result = conn.exec_query(query)
36
+ end
37
+
38
+
39
+ # Format the result
40
+ {
41
+ columns: result.columns,
42
+ rows: result.rows.first(RESULT_LIMIT),
43
+ row_count: result.rows.length,
44
+ adapter: conn.adapter_name,
45
+ database: Rails.configuration.database_configuration.dig(Rails.env, "database")
46
+ }
47
+ end
48
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Tidewave::Tools::GetLogs < Tidewave::Tools::Base
4
+ tool_name "get_logs"
5
+ description <<~DESCRIPTION
6
+ Returns all log output, excluding logs that were caused by other tool calls.
7
+
8
+ Use this tool to check for request logs or potentially logged errors.
9
+ DESCRIPTION
10
+
11
+ arguments do
12
+ required(:tail).filled(:integer).description("The number of log entries to return from the end of the log")
13
+ end
14
+
15
+ def call(tail:)
16
+ log_file = Rails.root.join("log", "#{Rails.env}.log")
17
+ return "Log file not found" unless File.exist?(log_file)
18
+
19
+ logs = File.readlines(log_file).last(tail)
20
+ logs.join
21
+ end
22
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Tidewave::Tools::GetModels < Tidewave::Tools::Base
4
+ tool_name "get_models"
5
+ description <<~DESCRIPTION
6
+ Returns a list of all models in the application and their relationships.
7
+ DESCRIPTION
8
+
9
+ def call
10
+ # Ensure all models are loaded
11
+ Rails.application.eager_load!
12
+
13
+ models = ActiveRecord::Base.descendants.map do |model|
14
+ { name: model.name, relationships: get_relationships(model) }
15
+ end
16
+
17
+ models.to_json
18
+ end
19
+
20
+ private
21
+
22
+ def get_relationships(model)
23
+ model.reflect_on_all_associations.map do |association|
24
+ { name: association.name, type: association.macro }
25
+ end.compact_blank
26
+ end
27
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/string/inflections"
4
+ require "active_support/core_ext/object/blank"
5
+
6
+ class Tidewave::Tools::GetSourceLocation < Tidewave::Tools::Base
7
+ tool_name "get_source_location"
8
+
9
+ description <<~DESCRIPTION
10
+ Returns the source location for the given module (or function).
11
+
12
+ This works for modules in the current project, as well as dependencies.
13
+
14
+ This tool only works if you know the specific module (and optionally function) that is being targeted.
15
+ If that is the case, prefer this tool over grepping the file system.
16
+ DESCRIPTION
17
+
18
+ arguments do
19
+ required(:module_name).filled(:string).description("The module to get source location for. When this is the single argument passed, the entire module source is returned.")
20
+ optional(:function_name).filled(:string).description("The function to get source location for. When used, a module must also be passed.")
21
+ end
22
+
23
+
24
+ def call(module_name:, function_name: nil)
25
+ file_path, line_number = get_source_location(module_name, function_name)
26
+
27
+ {
28
+ file_path: file_path,
29
+ line_number: line_number
30
+ }.to_json
31
+ end
32
+
33
+ private
34
+
35
+ def get_source_location(module_name, function_name)
36
+ begin
37
+ module_ref = module_name.constantize
38
+ rescue NameError
39
+ raise NameError, "Module #{module_name} not found"
40
+ end
41
+
42
+ return get_method_definition(module_ref, function_name) if function_name.present?
43
+
44
+ module_ref.const_source_location(module_name)
45
+ end
46
+
47
+ def get_method_definition(module_ref, function_name)
48
+ module_ref.method(function_name).source_location
49
+ rescue NameError
50
+ get_instance_method_definition(module_ref, function_name)
51
+ end
52
+
53
+ def get_instance_method_definition(module_ref, function_name)
54
+ module_ref.instance_method(function_name).source_location
55
+ rescue NameError
56
+ raise NameError, "Method #{function_name} not found in module #{module_ref.name}"
57
+ end
58
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tidewave/file_tracker"
4
+
5
+ class Tidewave::Tools::GlobProjectFiles < Tidewave::Tools::Base
6
+ file_system_tool
7
+
8
+ tool_name "glob_project_files"
9
+ description "Searches for files matching the given glob pattern."
10
+
11
+ arguments do
12
+ required(:pattern).filled(:string).description('The glob pattern to match files against, e.g., \"**/*.ex\"')
13
+ end
14
+
15
+ def call(pattern:)
16
+ Dir.glob(pattern, base: Tidewave::FileTracker.git_root)
17
+ end
18
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Tidewave::Tools::GrepProjectFiles < Tidewave::Tools::Base
4
+ file_system_tool
5
+
6
+ def self.ripgrep_executable
7
+ @ripgrep_executable ||= `which rg`.strip
8
+ end
9
+
10
+ def self.ripgrep_available?
11
+ ripgrep_executable.present?
12
+ end
13
+
14
+ def self.description
15
+ "Searches for text patterns in files using #{ripgrep_available? ? 'ripgrep' : 'a grep variant'}."
16
+ end
17
+ tool_name "grep_project_files"
18
+
19
+ arguments do
20
+ required(:pattern).filled(:string).description("The pattern to search for")
21
+ optional(:glob).filled(:string).description(
22
+ 'Optional glob pattern to filter which files to search in, e.g., \"**/*.ex\". Note that if a glob pattern is used, the .gitignore file will be ignored.'
23
+ )
24
+ optional(:case_sensitive).filled(:bool).description("Whether the search should be case-sensitive. Defaults to false.")
25
+ optional(:max_results).filled(:integer).description("Maximum number of results to return. Defaults to 100.")
26
+ end
27
+
28
+ def call(pattern:, glob: "**/*", case_sensitive: false, max_results: 100)
29
+ if self.class.ripgrep_available?
30
+ execute_ripgrep(pattern, glob, case_sensitive, max_results)
31
+ else
32
+ execute_grep(pattern, glob, case_sensitive, max_results)
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def execute_ripgrep(pattern, glob, case_sensitive, max_results)
39
+ command = [ self.class.ripgrep_executable ]
40
+ command << "--no-require-git" # ignore gitignored files
41
+ command << "--json" # formatted as json
42
+ command << "--max-count=#{max_results}"
43
+ command << "--ignore-case" unless case_sensitive
44
+ command << "--glob=#{glob}" if glob.present?
45
+ command << pattern
46
+ command << "." # Search in current directory
47
+
48
+ results = `#{command.join(" ")} 2>&1`
49
+
50
+ # Process the results as needed
51
+ format_ripgrep_results(results)
52
+ end
53
+
54
+ def execute_grep(pattern, glob, case_sensitive, max_results)
55
+ files = Tidewave::Tools::GlobProjectFiles.new.call(pattern: glob)
56
+ results = []
57
+ files.each do |file|
58
+ next unless File.file?(file)
59
+
60
+ begin
61
+ file_matches = 0
62
+ line_number = 0
63
+
64
+ File.foreach(file) do |line|
65
+ line_number += 1
66
+
67
+ # Check if line matches pattern with proper case sensitivity
68
+ if case_sensitive
69
+ next unless line.include?(pattern)
70
+ else
71
+ next unless line.downcase.include?(pattern.downcase)
72
+ end
73
+
74
+ results << {
75
+ "path" => file,
76
+ "line_number" => line_number,
77
+ "content" => line.strip
78
+ }
79
+
80
+ file_matches += 1
81
+ # Stop processing this file if we've reached max results for it
82
+ break if file_matches >= max_results
83
+ end
84
+ rescue => e
85
+ # Skip files that can't be read (e.g., binary files)
86
+ next
87
+ end
88
+ end
89
+
90
+ results.to_json
91
+ end
92
+
93
+ def format_ripgrep_results(results)
94
+ parsed_results = results.split("\n").map(&:strip).reject(&:empty?).map do |line|
95
+ JSON.parse(line)
96
+ end
97
+
98
+ parsed_results.map do |result|
99
+ next if result["type"] != "match"
100
+
101
+ {
102
+ "path" => result.dig("data", "path", "text"),
103
+ "line_number" => result.dig("data", "line_number"),
104
+ "content" => result.dig("data", "lines", "text").strip
105
+ }
106
+ end.compact.to_json
107
+ end
108
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tidewave/file_tracker"
4
+
5
+ class Tidewave::Tools::ListProjectFiles < Tidewave::Tools::Base
6
+ file_system_tool
7
+
8
+ tool_name "list_project_files"
9
+ description "Returns a list of all files in the project that are not ignored by .gitignore."
10
+
11
+ def call
12
+ Tidewave::FileTracker.project_files
13
+ end
14
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+
5
+ class Tidewave::Tools::PackageSearch < Tidewave::Tools::Base
6
+ tool_name "package_search"
7
+ description <<~DESCRIPTION
8
+ Searches for packages on RubyGems.
9
+
10
+ Use this tool if you need to find new packages to add to the project. Before using this tool,
11
+ get an overview of the existing dependencies by using the `project_eval` tool and executing
12
+ `Gem::Specification.map { |gem| [gem.name, gem.version] }`.
13
+
14
+ The results are paginated, with 30 packages per page. Use the `page` parameter to fetch a specific page.
15
+ DESCRIPTION
16
+
17
+ arguments do
18
+ required(:search).filled(:string).description("The search term")
19
+ optional(:page).filled(:integer, gt?: 0).description("The page number to fetch. Must be greater than 0. Defaults to 1.")
20
+ end
21
+
22
+ def call(search:, page: 1)
23
+ response = rubygems_client.get("/api/v1/search.json?query=#{search}&page=#{page}")
24
+
25
+ response.body
26
+ end
27
+
28
+ private
29
+
30
+ def rubygems_client
31
+ @rubygems_client ||= Faraday.new(url: "https://rubygems.org") do |faraday|
32
+ faraday.response :raise_error
33
+
34
+ faraday.adapter Faraday.default_adapter
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Tidewave::Tools::ProjectEval < Tidewave::Tools::Base
4
+ tool_name "project_eval"
5
+ description <<~DESCRIPTION
6
+ Evaluates Ruby code in the context of the project.
7
+
8
+ The current Ruby version is: #{RUBY_VERSION}
9
+
10
+ The code is executed in the context of the user's project, therefore use this tool any
11
+ time you need to evaluate code, for example to test the behavior of a function or to debug
12
+ something. The tool also returns anything written to standard output.
13
+ DESCRIPTION
14
+
15
+ arguments do
16
+ required(:code).filled(:string).description("The Ruby code to evaluate")
17
+ end
18
+
19
+ def call(code:)
20
+ original_stdout = $stdout
21
+ original_stderr = $stderr
22
+
23
+ stdout_capture = StringIO.new
24
+ stderr_capture = StringIO.new
25
+ $stdout = stdout_capture
26
+ $stderr = stderr_capture
27
+
28
+ begin
29
+ result = eval(code)
30
+ stdout = stdout_capture.string
31
+ stderr = stderr_capture.string
32
+
33
+ if stdout.empty? && stderr.empty?
34
+ result
35
+ else
36
+ {
37
+ stdout: stdout,
38
+ stderr: stderr,
39
+ result: result
40
+ }
41
+ end
42
+ ensure
43
+ $stdout = original_stdout
44
+ $stderr = original_stderr
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tidewave/file_tracker"
4
+
5
+ class Tidewave::Tools::ReadProjectFile < Tidewave::Tools::Base
6
+ file_system_tool
7
+
8
+ tool_name "read_project_file"
9
+ description <<~DESCRIPTION
10
+ Returns the contents of the given file. Matches the `resources/read` MCP method.
11
+ DESCRIPTION
12
+
13
+ arguments do
14
+ required(:path).filled(:string).description("The path to the file to read. It is relative to the project root.")
15
+ end
16
+
17
+ def call(path:)
18
+ Tidewave::FileTracker.read_file(path)
19
+ end
20
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ class Tidewave::Tools::ShellEval < Tidewave::Tools::Base
6
+ file_system_tool
7
+ class CommandFailedError < StandardError; end
8
+
9
+ tool_name "shell_eval"
10
+ description <<~DESCRIPTION
11
+ Executes a shell command in the project root directory.
12
+
13
+ Avoid using this tool for file operations. Instead, rely on dedicated file system tools, if available.
14
+
15
+ The operating system is of flavor #{RUBY_PLATFORM}.
16
+
17
+ Only use this tool if other means are not available.
18
+ DESCRIPTION
19
+
20
+ arguments do
21
+ required(:command).filled(:string).description("The shell command to execute. Avoid using this for file operations; use dedicated file system tools instead.")
22
+ end
23
+
24
+ def call(command:)
25
+ stdout, status = Open3.capture2e(command)
26
+ raise CommandFailedError, "Command failed with status #{status.exitstatus}:\n\n#{stdout}" unless status.exitstatus.zero?
27
+
28
+ stdout.strip
29
+ end
30
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tidewave/file_tracker"
4
+
5
+ class Tidewave::Tools::WriteProjectFile < Tidewave::Tools::Base
6
+ file_system_tool
7
+
8
+ tool_name "write_project_file"
9
+ description <<~DESCRIPTION
10
+ Writes a file to the file system. If the file already exists, it will be overwritten.
11
+
12
+ Note that this tool will fail if the file wasn't previously read with the `read_project_file` tool.
13
+ DESCRIPTION
14
+
15
+ arguments do
16
+ required(:path).filled(:string).description("The path to the file to write. It is relative to the project root.")
17
+ required(:content).filled(:string).description("The content to write to the file")
18
+ end
19
+
20
+ def call(path:, content:)
21
+ Tidewave::FileTracker.validate_path_is_writable!(path)
22
+
23
+ Tidewave::FileTracker.write_file(path, content)
24
+ end
25
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tidewave
4
+ VERSION = "0.1.0"
5
+ end
data/lib/tidewave.rb ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tidewave/version"
4
+ require "tidewave/railtie"
5
+
6
+ module Tidewave
7
+ PATH_PREFIX = "/tidewave"
8
+ MESSAGES_ROUTE = "messages"
9
+ SSE_ROUTE = "mcp"
10
+ end
metadata ADDED
@@ -0,0 +1,124 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: tidewave
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Yorick Jacquin
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2025-04-29 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rails
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: 7.2.0
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: 7.2.0
27
+ - !ruby/object:Gem::Dependency
28
+ name: fast-mcp
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: 1.3.0
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: 1.3.0
41
+ - !ruby/object:Gem::Dependency
42
+ name: faraday
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: 2.13.0
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: 2.13.0
55
+ - !ruby/object:Gem::Dependency
56
+ name: rack
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '2.0'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '2.0'
69
+ description: Tidewave for Rails
70
+ email:
71
+ - support@tidewave.ai
72
+ executables: []
73
+ extensions: []
74
+ extra_rdoc_files: []
75
+ files:
76
+ - README.md
77
+ - Rakefile
78
+ - config/database.yml
79
+ - lib/tidewave.rb
80
+ - lib/tidewave/file_tracker.rb
81
+ - lib/tidewave/railtie.rb
82
+ - lib/tidewave/tool_resolver.rb
83
+ - lib/tidewave/tools/base.rb
84
+ - lib/tidewave/tools/edit_project_file.rb
85
+ - lib/tidewave/tools/execute_sql_query.rb
86
+ - lib/tidewave/tools/get_logs.rb
87
+ - lib/tidewave/tools/get_models.rb
88
+ - lib/tidewave/tools/get_source_location.rb
89
+ - lib/tidewave/tools/glob_project_files.rb
90
+ - lib/tidewave/tools/grep_project_files.rb
91
+ - lib/tidewave/tools/list_project_files.rb
92
+ - lib/tidewave/tools/package_search.rb
93
+ - lib/tidewave/tools/project_eval.rb
94
+ - lib/tidewave/tools/read_project_file.rb
95
+ - lib/tidewave/tools/shell_eval.rb
96
+ - lib/tidewave/tools/write_project_file.rb
97
+ - lib/tidewave/version.rb
98
+ homepage: https://tidewave.ai/
99
+ licenses:
100
+ - Apache-2.0
101
+ metadata:
102
+ homepage_uri: https://tidewave.ai/
103
+ source_code_uri: https://github.com/tidewave-ai/tidewave_rails
104
+ changelog_uri: https://github.com/tidewave-ai/tidewave_rails/blob/main/CHANGELOG.md
105
+ post_install_message:
106
+ rdoc_options: []
107
+ require_paths:
108
+ - lib
109
+ required_ruby_version: !ruby/object:Gem::Requirement
110
+ requirements:
111
+ - - ">="
112
+ - !ruby/object:Gem::Version
113
+ version: '0'
114
+ required_rubygems_version: !ruby/object:Gem::Requirement
115
+ requirements:
116
+ - - ">="
117
+ - !ruby/object:Gem::Version
118
+ version: '0'
119
+ requirements: []
120
+ rubygems_version: 3.4.1
121
+ signing_key:
122
+ specification_version: 4
123
+ summary: Tidewave for Rails
124
+ test_files: []