tidewave 0.1.2 → 0.2.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.
@@ -1,40 +1,49 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "fast_mcp"
4
3
  require "logger"
5
4
  require "fileutils"
6
- require "tidewave/tool_resolver"
7
5
  require "tidewave/configuration"
6
+ require "tidewave/middleware"
7
+ require "tidewave/exceptions_middleware"
8
+
9
+ gem_tools_path = File.expand_path("tools/**/*.rb", __dir__)
10
+ Dir[gem_tools_path].each { |f| require f }
8
11
 
9
12
  module Tidewave
10
13
  class Railtie < Rails::Railtie
11
- config.tidewave = Tidewave::Configuration.new
12
-
13
- initializer "tidewave.setup_mcp" do |app|
14
- # Prevent MCP server from being mounted if Rails is not running in development mode
15
- raise "For security reasons, Tidewave is only supported in development mode" unless Rails.env.development?
16
-
17
- config = app.config.tidewave
18
-
19
- # Set up MCP server with the host application
20
- FastMcp.mount_in_rails(
21
- app,
22
- name: "tidewave",
23
- version: Tidewave::VERSION,
24
- path_prefix: Tidewave::PATH_PREFIX,
25
- messages_route: Tidewave::MESSAGES_ROUTE,
26
- sse_route: Tidewave::SSE_ROUTE,
27
- logger: config.logger,
28
- allowed_origins: config.allowed_origins,
29
- localhost_only: config.localhost_only,
30
- allowed_ips: config.allowed_ips
31
- ) do |server|
32
- app.config.before_initialize do
33
- # Register a custom middleware to register tools depending on `include_fs_tools` query parameter
34
- server.register_tools(*Tidewave::ToolResolver::ALL_TOOLS)
35
- app.middleware.use Tidewave::ToolResolver, server
14
+ config.tidewave = Tidewave::Configuration.new()
15
+
16
+ initializer "tidewave.setup" do |app|
17
+ unless app.config.enable_reloading
18
+ raise "For security reasons, Tidewave is only supported in environments where config.enable_reloading is true (typically development)"
19
+ end
20
+
21
+ app.config.middleware.insert_after(
22
+ ActionDispatch::Callbacks,
23
+ Tidewave::Middleware,
24
+ app.config.tidewave
25
+ )
26
+
27
+ app.config.after_initialize do
28
+ # If the user configured CSP, we need to alter it in dev
29
+ # to allow TC to run browser_eval.
30
+ app.config.content_security_policy.try do |content_security_policy|
31
+ content_security_policy.directives["script-src"].try do |script_src|
32
+ script_src << "'unsafe-eval'" unless script_src.include?("'unsafe-eval'")
33
+ end
36
34
  end
37
35
  end
38
36
  end
37
+
38
+ initializer "tidewave.intercept_exceptions" do |app|
39
+ # We intercept exceptions from DebugExceptions, format the
40
+ # information as text and inject into the exception page html.
41
+
42
+ ActionDispatch::DebugExceptions.register_interceptor do |request, exception|
43
+ request.set_header("tidewave.exception", exception)
44
+ end
45
+
46
+ app.middleware.insert_before(ActionDispatch::DebugExceptions, Tidewave::ExceptionsMiddleware)
47
+ end
39
48
  end
40
49
  end
@@ -3,13 +3,6 @@
3
3
  module Tidewave
4
4
  module Tools
5
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
6
  end
14
7
  end
15
8
  end
@@ -3,7 +3,7 @@
3
3
  require "tidewave/file_tracker"
4
4
 
5
5
  class Tidewave::Tools::EditProjectFile < Tidewave::Tools::Base
6
- file_system_tool
6
+ tags :file_system_tool
7
7
 
8
8
  tool_name "edit_project_file"
9
9
  description <<~DESCRIPTION
@@ -26,13 +26,14 @@ class Tidewave::Tools::EditProjectFile < Tidewave::Tools::Base
26
26
  required(:path).filled(:string).description("The path to the file to edit. It is relative to the project root.")
27
27
  required(:old_string).filled(:string).description("The string to search for")
28
28
  required(:new_string).filled(:string).description("The string to replace the old_string with")
29
+ optional(:atime).filled(:integer).hidden.description("The Unix timestamp this file was last accessed. Not to be used.")
29
30
  end
30
31
 
31
- def call(path:, old_string:, new_string:)
32
+ def call(path:, old_string:, new_string:, atime: nil)
32
33
  # Check if the file exists within the project root and has been read
33
- Tidewave::FileTracker.validate_path_is_editable!(path)
34
+ Tidewave::FileTracker.validate_path_is_editable!(path, atime)
34
35
 
35
- old_content = Tidewave::FileTracker.read_file(path)
36
+ _mtime, old_content = Tidewave::FileTracker.read_file(path)
36
37
 
37
38
  # Ensure old_string is unique within the file
38
39
  scan_result = old_content.scan(old_string)
@@ -40,7 +41,7 @@ class Tidewave::Tools::EditProjectFile < Tidewave::Tools::Base
40
41
  raise ArgumentError, "old_string is not unique" if scan_result.size > 1
41
42
 
42
43
  new_content = old_content.sub(old_string, new_string)
43
-
44
44
  Tidewave::FileTracker.write_file(path, new_content)
45
+ "OK"
45
46
  end
46
47
  end
@@ -3,7 +3,7 @@
3
3
  class Tidewave::Tools::ExecuteSqlQuery < Tidewave::Tools::Base
4
4
  tool_name "execute_sql_query"
5
5
  description <<~DESCRIPTION
6
- Executes the given SQL query against the ActiveRecord database connection.
6
+ Executes the given SQL query against the database connection.
7
7
  Returns the result as a Ruby data structure.
8
8
 
9
9
  Note that the output is limited to 50 rows at a time. If you need to see more, perform additional calls
@@ -25,24 +25,6 @@ class Tidewave::Tools::ExecuteSqlQuery < Tidewave::Tools::Base
25
25
  RESULT_LIMIT = 50
26
26
 
27
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
- }
28
+ Tidewave::DatabaseAdapter.current.execute_query(query, arguments)
47
29
  end
48
30
  end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Tidewave::Tools::GetDocs < Tidewave::Tools::Base
4
+ tool_name "get_docs"
5
+
6
+ description <<~DESCRIPTION
7
+ Returns the documentation for the given reference.
8
+
9
+ The reference may be a constant, most commonly classes and modules
10
+ such as `String`, an instance method, such as `String#gsub`, or class
11
+ method, such as `File.executable?`
12
+
13
+ This works for methods in the current project, as well as dependencies.
14
+
15
+ This tool only works if you know the specific constant/method being targeted.
16
+ If that is the case, prefer this tool over grepping the file system.
17
+ DESCRIPTION
18
+
19
+ arguments do
20
+ required(:reference).filled(:string).description("The constant/method to lookup, such String, String#gsub or File.executable?")
21
+ end
22
+
23
+ def call(reference:)
24
+ file_path, line_number = Tidewave::Tools::GetSourceLocation.get_source_location(reference)
25
+
26
+ if file_path
27
+ extract_documentation(file_path, line_number)
28
+ else
29
+ raise NameError, "could not find docs for #{reference}"
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def extract_documentation(file_path, line_number)
36
+ return nil unless File.exist?(file_path)
37
+
38
+ lines = File.readlines(file_path)
39
+ return nil if line_number <= 0 || line_number > lines.length
40
+
41
+ # Start from the line before the method definition
42
+ current_line = line_number - 2 # Convert to 0-based index and go one line up
43
+ comment_lines = []
44
+
45
+ # Collect comment lines going backwards
46
+ while current_line >= 0
47
+ line = lines[current_line].chomp.strip
48
+
49
+ if line.start_with?("#")
50
+ comment_lines.unshift(line.sub(/^#\s|^#/, ""))
51
+ elsif line.empty?
52
+ # Skip empty lines but continue looking for comments
53
+ else
54
+ # Hit a non-comment, non-empty line, stop collecting
55
+ break
56
+ end
57
+
58
+ current_line -= 1
59
+ end
60
+
61
+ return nil if comment_lines.empty?
62
+ comment_lines.join("\n")
63
+ end
64
+ end
@@ -3,25 +3,36 @@
3
3
  class Tidewave::Tools::GetModels < Tidewave::Tools::Base
4
4
  tool_name "get_models"
5
5
  description <<~DESCRIPTION
6
- Returns a list of all models in the application and their relationships.
6
+ Returns a list of all models in the application.
7
7
  DESCRIPTION
8
8
 
9
9
  def call
10
10
  # Ensure all models are loaded
11
11
  Rails.application.eager_load!
12
12
 
13
- models = ActiveRecord::Base.descendants.map do |model|
14
- { name: model.name, relationships: get_relationships(model) }
15
- end
16
-
17
- models.to_json
13
+ base_class = Tidewave::DatabaseAdapter.current.get_base_class
14
+ base_class.descendants.map do |model|
15
+ if location = get_relative_source_location(model.name)
16
+ "* #{model.name} at #{location}"
17
+ else
18
+ "* #{model.name}"
19
+ end
20
+ end.join("\n")
18
21
  end
19
22
 
20
23
  private
21
24
 
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
25
+ def get_relative_source_location(model_name)
26
+ source_location = Object.const_source_location(model_name)
27
+ return nil if source_location.blank?
28
+
29
+ file_path, line_number = source_location
30
+ begin
31
+ relative_path = Pathname.new(file_path).relative_path_from(Rails.root)
32
+ "#{relative_path}:#{line_number}"
33
+ rescue ArgumentError
34
+ # If the path cannot be made relative, return the absolute path
35
+ "#{file_path}:#{line_number}"
36
+ end
26
37
  end
27
38
  end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ class Tidewave::Tools::GetPackageLocation < Tidewave::Tools::Base
6
+ tool_name "get_package_location"
7
+
8
+ description <<~DESCRIPTION
9
+ Returns the location of dependency packages.
10
+ You can use this tool to get the location of any project dependency. Optionally,
11
+ a specific dependency name can be provided to only return the location of that dependency.
12
+ Use the result in combination with shell tools like grep to look for specific
13
+ code inside dependencies.
14
+ DESCRIPTION
15
+
16
+ arguments do
17
+ optional(:package).maybe(:string).description(
18
+ "The name of the package to get the location of. If not provided, the location of all packages will be returned."
19
+ )
20
+ end
21
+
22
+ def call(package: nil)
23
+ raise "get_package_location only works with projects using Bundler" unless defined?(Bundler)
24
+ specs = Bundler.load.specs
25
+
26
+ if package
27
+ spec = specs.find { |s| s.name == package }
28
+ if spec
29
+ spec.full_gem_path
30
+ else
31
+ raise "Package #{package} not found. Check your Gemfile for available packages."
32
+ end
33
+ else
34
+ # For all packages, return a formatted string with package names and locations
35
+ specs.map do |spec|
36
+ relative_path = Pathname.new(spec.full_gem_path).relative_path_from(Pathname.new(Dir.pwd))
37
+ "#{spec.name}: #{relative_path}"
38
+ end.join("\n")
39
+ end
40
+ end
41
+ end
@@ -1,58 +1,61 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "active_support/core_ext/string/inflections"
4
- require "active_support/core_ext/object/blank"
5
-
6
3
  class Tidewave::Tools::GetSourceLocation < Tidewave::Tools::Base
7
4
  tool_name "get_source_location"
8
5
 
9
6
  description <<~DESCRIPTION
10
- Returns the source location for the given module (or function).
7
+ Returns the source location for the given reference.
8
+
9
+ The reference may be a constant, most commonly classes and modules
10
+ such as `String`, an instance method, such as `String#gsub`, or class
11
+ method, such as `File.executable?`
11
12
 
12
- This works for modules in the current project, as well as dependencies.
13
+ This works for methods in the current project, as well as dependencies.
13
14
 
14
- This tool only works if you know the specific module (and optionally function) that is being targeted.
15
+ This tool only works if you know the specific constant/method being targeted.
15
16
  If that is the case, prefer this tool over grepping the file system.
16
17
  DESCRIPTION
17
18
 
18
19
  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.")
20
+ required(:reference).filled(:string).description("The constant/method to lookup, such String, String#gsub or File.executable?")
21
21
  end
22
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
23
+ def call(reference:)
24
+ file_path, line_number = self.class.get_source_location(reference)
25
+
26
+ if file_path
27
+ begin
28
+ relative_path = Pathname.new(file_path).relative_path_from(Rails.root)
29
+ "#{relative_path}:#{line_number}"
30
+ rescue ArgumentError
31
+ # If the path cannot be made relative, return the absolute path
32
+ "#{file_path}:#{line_number}"
33
+ end
34
+ else
35
+ raise NameError, "could not find source location for #{reference}"
36
+ end
31
37
  end
32
38
 
33
- private
39
+ def self.get_source_location(reference)
40
+ constant_path, selector, method_name = reference.rpartition(/\.|#/)
41
+
42
+ # There are no selectors, so the method_name is a constant path
43
+ return Object.const_source_location(method_name) if selector.empty?
34
44
 
35
- def get_source_location(module_name, function_name)
36
45
  begin
37
- module_ref = module_name.constantize
38
- rescue NameError
39
- raise NameError, "Module #{module_name} not found"
46
+ mod = Object.const_get(constant_path)
47
+ rescue NameError => e
48
+ raise e
49
+ rescue
50
+ raise "wrong or invalid reference #{reference}"
40
51
  end
41
52
 
42
- return get_method_definition(module_ref, function_name) if function_name.present?
53
+ raise "reference #{constant_path} does not point a class/module" unless mod.is_a?(Module)
43
54
 
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}"
55
+ if selector == "#"
56
+ mod.instance_method(method_name).source_location
57
+ else
58
+ mod.method(method_name).source_location
59
+ end
57
60
  end
58
61
  end
@@ -3,12 +3,24 @@
3
3
  require "tidewave/file_tracker"
4
4
 
5
5
  class Tidewave::Tools::ListProjectFiles < Tidewave::Tools::Base
6
- file_system_tool
6
+ tags :file_system_tool
7
7
 
8
8
  tool_name "list_project_files"
9
- description "Returns a list of all files in the project that are not ignored by .gitignore."
9
+ description <<~DESC
10
+ Returns a list of files in the project.
10
11
 
11
- def call
12
- Tidewave::FileTracker.project_files
12
+ By default, when no arguments are passed, it returns all files in the project that
13
+ are not ignored by .gitignore.
14
+
15
+ Optionally, a glob_pattern can be passed to filter this list.
16
+ DESC
17
+
18
+ arguments do
19
+ optional(:glob_pattern).maybe(:string).description("Optional: a glob pattern to filter the listed files")
20
+ optional(:include_ignored).maybe(:bool).description("Optional: whether to include files that are ignored by .gitignore. Defaults to false. WARNING: Use with targeted glob patterns to avoid listing excessive files from dependencies or build directories.")
21
+ end
22
+
23
+ def call(glob_pattern: nil, include_ignored: false)
24
+ Tidewave::FileTracker.project_files(glob_pattern: glob_pattern, include_ignored: include_ignored)
13
25
  end
14
26
  end
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "timeout"
4
+ require "json"
5
+
3
6
  class Tidewave::Tools::ProjectEval < Tidewave::Tools::Base
4
7
  tool_name "project_eval"
5
8
  description <<~DESCRIPTION
@@ -15,9 +18,12 @@ class Tidewave::Tools::ProjectEval < Tidewave::Tools::Base
15
18
 
16
19
  arguments do
17
20
  required(:code).filled(:string).description("The Ruby code to evaluate")
21
+ optional(:arguments).value(:array).description("The arguments to pass to evaluation. They are available inside the evaluated code as `arguments`.")
22
+ optional(:timeout).filled(:integer).description("The timeout in milliseconds. If the evaluation takes longer than this, it will be terminated. Defaults to 30000 (30 seconds).")
23
+ optional(:json).filled(:bool).description("Whether to return the result as JSON with structured output containing result, success, stdout, and stderr fields. Defaults to false.")
18
24
  end
19
25
 
20
- def call(code:)
26
+ def call(code:, arguments: [], timeout: 30_000, json: false)
21
27
  original_stdout = $stdout
22
28
  original_stderr = $stderr
23
29
 
@@ -27,22 +33,56 @@ class Tidewave::Tools::ProjectEval < Tidewave::Tools::Base
27
33
  $stderr = stderr_capture
28
34
 
29
35
  begin
30
- result = eval(code)
36
+ timeout_seconds = timeout / 1000.0
37
+
38
+ success, result = begin
39
+ Timeout.timeout(timeout_seconds) do
40
+ [ true, eval(code, eval_binding(arguments)) ]
41
+ end
42
+ rescue Timeout::Error
43
+ [ false, "Timeout::Error: Evaluation timed out after #{timeout} milliseconds." ]
44
+ rescue => e
45
+ [ false, e.full_message ]
46
+ end
47
+
31
48
  stdout = stdout_capture.string
32
49
  stderr = stderr_capture.string
33
50
 
34
- if stdout.empty? && stderr.empty?
35
- result
36
- else
37
- {
51
+ if json
52
+ JSON.generate({
53
+ result: result,
54
+ success: success,
38
55
  stdout: stdout,
39
- stderr: stderr,
40
- result: result
41
- }
56
+ stderr: stderr
57
+ })
58
+ elsif stdout.empty? && stderr.empty?
59
+ # We explicitly call to_s so the result is not accidentally
60
+ # parsed as a JSON response by FastMCP.
61
+ result.to_s
62
+ else
63
+ <<~OUTPUT
64
+ STDOUT:
65
+
66
+ #{stdout}
67
+
68
+ STDERR:
69
+
70
+ #{stderr}
71
+
72
+ Result:
73
+
74
+ #{result}
75
+ OUTPUT
42
76
  end
43
77
  ensure
44
78
  $stdout = original_stdout
45
79
  $stderr = original_stderr
46
80
  end
47
81
  end
82
+
83
+ private
84
+
85
+ def eval_binding(arguments)
86
+ binding
87
+ end
48
88
  end
@@ -3,18 +3,23 @@
3
3
  require "tidewave/file_tracker"
4
4
 
5
5
  class Tidewave::Tools::ReadProjectFile < Tidewave::Tools::Base
6
- file_system_tool
6
+ tags :file_system_tool
7
7
 
8
8
  tool_name "read_project_file"
9
9
  description <<~DESCRIPTION
10
- Returns the contents of the given file. Matches the `resources/read` MCP method.
10
+ Returns the contents of the given file.
11
+ Supports an optional line_offset and count. To read the full file, only the path needs to be passed.
11
12
  DESCRIPTION
12
13
 
13
14
  arguments do
14
15
  required(:path).filled(:string).description("The path to the file to read. It is relative to the project root.")
16
+ optional(:line_offset).filled(:integer).description("Optional: the starting line offset from which to read. Defaults to 0.")
17
+ optional(:count).filled(:integer).description("Optional: the number of lines to read. Defaults to all.")
15
18
  end
16
19
 
17
- def call(path:)
18
- Tidewave::FileTracker.read_file(path)
20
+ def call(path:, **keywords)
21
+ Tidewave::FileTracker.validate_path_access!(path)
22
+ _meta[:mtime], contents = Tidewave::FileTracker.read_file(path, **keywords)
23
+ contents
19
24
  end
20
25
  end
@@ -3,7 +3,7 @@
3
3
  require "open3"
4
4
 
5
5
  class Tidewave::Tools::ShellEval < Tidewave::Tools::Base
6
- file_system_tool
6
+ tags :file_system_tool
7
7
  class CommandFailedError < StandardError; end
8
8
 
9
9
  tool_name "shell_eval"
@@ -3,7 +3,7 @@
3
3
  require "tidewave/file_tracker"
4
4
 
5
5
  class Tidewave::Tools::WriteProjectFile < Tidewave::Tools::Base
6
- file_system_tool
6
+ tags :file_system_tool
7
7
 
8
8
  tool_name "write_project_file"
9
9
  description <<~DESCRIPTION
@@ -15,11 +15,14 @@ class Tidewave::Tools::WriteProjectFile < Tidewave::Tools::Base
15
15
  arguments do
16
16
  required(:path).filled(:string).description("The path to the file to write. It is relative to the project root.")
17
17
  required(:content).filled(:string).description("The content to write to the file")
18
+ optional(:atime).filled(:integer).hidden.description("The Unix timestamp this file was last accessed. Not to be used.")
18
19
  end
19
20
 
20
- def call(path:, content:)
21
- Tidewave::FileTracker.validate_path_is_writable!(path)
22
-
21
+ def call(path:, content:, atime: nil)
22
+ Tidewave::FileTracker.validate_path_is_writable!(path, atime)
23
23
  Tidewave::FileTracker.write_file(path, content)
24
+ _meta[:mtime] = Time.now.to_i
25
+
26
+ "OK"
24
27
  end
25
28
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tidewave
4
- VERSION = "0.1.2"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/tidewave.rb CHANGED
@@ -2,9 +2,12 @@
2
2
 
3
3
  require "tidewave/version"
4
4
  require "tidewave/railtie"
5
+ require "tidewave/database_adapter"
5
6
 
7
+ # Ensure DatabaseAdapters module is available
6
8
  module Tidewave
7
- PATH_PREFIX = "/tidewave"
8
- MESSAGES_ROUTE = "messages"
9
- SSE_ROUTE = "mcp"
9
+ module DatabaseAdapters
10
+ # This module is defined here to ensure it's available for autoloading
11
+ # Individual adapters are loaded on-demand in database_adapter.rb
12
+ end
10
13
  end