tidewave 0.1.3 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6d4f421f00436367f21d189e0fe49ea8a1ce4f611457b720f62027b4b8f080dc
4
- data.tar.gz: 6bb95b976f34b5520b54fb8a0789d1adf18c591cef285bcd5500f591b2c4d1a3
3
+ metadata.gz: d708afbfcf6d89cc1456bd7bcefdc660952571cda0c6635b8c6b14b8fa31d91e
4
+ data.tar.gz: da923b2f69608fe95eb11f76795cef1598f9cc24941afe50081afc4b76c66cfa
5
5
  SHA512:
6
- metadata.gz: 851460abeef4e3d29d0d475a83f1e962bb0a6279708e25cc25ed2b8620782e48f837f8db279fac1da822af3c1d5472208be2340ca691ef5a226ab7bd7f4292f9
7
- data.tar.gz: 48f5e731497bf822329c2de6865361c0d31bfe5fa64afed0c213d75d8de4db08ed4f44aa7ef76e889803330c70ba202712f724d569650d2707dccd3debb74d11
6
+ metadata.gz: 9b0eaa2a77690e4dda1720e5c2c84b02ea93d9b69955e2e430745da483d30014ba3fc5d59dc40c92b0ccdedd4413f4198012813a33f2332fd7ca08a9fac79fc1
7
+ data.tar.gz: 501103bf39a69932a408ebf9e455640fc8fb97a507163d6b4f42faf27aead8811927ecc249858978c8c5a46213dbe5b1d8635ade60817f546645ec99fa4c203a
data/README.md CHANGED
@@ -6,17 +6,6 @@ assistant to your web framework runtime via [MCP](https://modelcontextprotocol.i
6
6
 
7
7
  [See our website](https://tidewave.ai) for more information.
8
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
9
  ## Installation
21
10
 
22
11
  You can install Tidewave by adding the `tidewave` gem to the development group in your Gemfile:
@@ -29,37 +18,47 @@ Tidewave will now run on the same port as your regular Rails application.
29
18
  In particular, the MCP is located by default at http://localhost:3000/tidewave/mcp.
30
19
  [You must configure your editor and AI assistants accordingly](https://hexdocs.pm/tidewave/mcp.html).
31
20
 
32
- ## Configuration
21
+ ## Troubleshooting
33
22
 
34
- You may configure the `tidewave` using the following syntax:
23
+ ### Localhost requirement
24
+
25
+ Tidewave expects your web application to be running on `localhost`. If you are not running on localhost, you may need to set some additional configuration. In particular, you must configure Tidewave to allow `allow_remote_access` and [optionally configure your Rails hosts](https://guides.rubyonrails.org/configuring.html#actiondispatch-hostauthorization). For example, in your `config/environments/development.rb`:
35
26
 
36
27
  ```ruby
37
- config.tidewave.allowed_ips = [IPAddr.new("192.168.97.1")]
28
+ config.hosts << "company.local"
29
+ config.tidewave.allow_remote_access = true
38
30
  ```
39
31
 
40
- The following options are available:
41
-
42
- * `:allowed_origins`
43
-
44
- * `:localhost_only`
45
-
46
- * `:allowed_ips`
32
+ If you want to use Docker for development, you either need to enable the configuration above or automatically redirect the relevant ports, as done by [devcontainers](https://code.visualstudio.com/docs/devcontainers/containers). See our [containars](https://hexdocs.pm/tidewave/containers.html) guide for more information.
47
33
 
48
- You can read more about this options in [FastMCP](https://github.com/yjacquin/fast_mcp) README.
34
+ ### Content security policy
49
35
 
50
- ## Considerations
36
+ If you have enabled Content-Security-Policy, Tidewave will automatically enable "unsafe-eval" under `script-src` in order for contextual browser testing to work correctly.
51
37
 
52
38
  ### Production Environment
53
39
 
54
- Tidewave is a powerful tool that can help you develop your web application faster and more efficiently.
55
- However, it is important to note that Tidewave is not meant to be used in a production environment.
40
+ Tidewave is a powerful tool that can help you develop your web application faster and more efficiently. However, it is important to note that Tidewave is not meant to be used in a production environment.
56
41
 
57
- Tidewave will raise an error if it is used in a production environment.
42
+ Tidewave will raise an error if it is used in any environment where code reloading is disabled (which typically includes production).
58
43
 
59
44
  ### Web server requirements
60
45
 
61
46
  Tidewave currently requires a threaded web server like Puma.
62
47
 
48
+ ## Configuration
49
+
50
+ You may configure `tidewave` using the following syntax:
51
+
52
+ ```ruby
53
+ config.tidewave.allow_remote_access = true
54
+ ```
55
+
56
+ The following options are available:
57
+
58
+ * `:allow_remote_access` - Tidewave only allows requests from localhost by default, even if your server listens on other interfaces as well. If you trust your network and need to access Tidewave from a different machine, this configuration can be set to `true`
59
+
60
+ * `:preferred_orm` - which ORM to use, either `:active_record` (default) or `:sequel`
61
+
63
62
  ## Acknowledgements
64
63
 
65
64
  A thank you to Yorick Jacquin, for creating [FastMCP](https://github.com/yjacquin/fast_mcp) and implementing the initial version of this project.
@@ -2,13 +2,14 @@
2
2
 
3
3
  module Tidewave
4
4
  class Configuration
5
- attr_accessor :logger, :allowed_origins, :localhost_only, :allowed_ips
5
+ attr_accessor :logger, :allow_remote_access, :preferred_orm, :credentials, :client_url
6
6
 
7
7
  def initialize
8
- @logger = Logger.new(STDOUT)
9
- @allowed_origins = nil
10
- @localhost_only = true
11
- @allowed_ips = nil
8
+ @logger = nil
9
+ @allow_remote_access = true
10
+ @preferred_orm = :active_record
11
+ @credentials = {}
12
+ @client_url = "https://tidewave.ai"
12
13
  end
13
14
  end
14
15
  end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tidewave
4
+ class DatabaseAdapter
5
+ class << self
6
+ def current
7
+ @current ||= create_adapter
8
+ end
9
+
10
+ def create_adapter
11
+ orm_type = Rails.application.config.tidewave.preferred_orm
12
+ case orm_type
13
+ when :active_record
14
+ require_relative "database_adapters/active_record"
15
+ DatabaseAdapters::ActiveRecord.new
16
+ when :sequel
17
+ require_relative "database_adapters/sequel"
18
+ DatabaseAdapters::Sequel.new
19
+ else
20
+ raise "Unknown preferred ORM: #{orm_type}"
21
+ end
22
+ end
23
+ end
24
+
25
+ def execute_query(query, arguments = [])
26
+ raise NotImplementedError, "Subclasses must implement execute_query"
27
+ end
28
+
29
+ def get_base_class
30
+ raise NotImplementedError, "Subclasses must implement get_base_class"
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tidewave
4
+ module DatabaseAdapters
5
+ class ActiveRecord < DatabaseAdapter
6
+ RESULT_LIMIT = 50
7
+
8
+ def execute_query(query, arguments = [])
9
+ conn = ::ActiveRecord::Base.connection
10
+
11
+ # Execute the query with prepared statement and arguments
12
+ if arguments.any?
13
+ result = conn.exec_query(query, "SQL", arguments)
14
+ else
15
+ result = conn.exec_query(query)
16
+ end
17
+
18
+ # Format the result
19
+ {
20
+ columns: result.columns,
21
+ rows: result.rows.first(RESULT_LIMIT),
22
+ row_count: result.rows.length,
23
+ adapter: conn.adapter_name,
24
+ database: database_name
25
+ }
26
+ end
27
+
28
+ def get_base_class
29
+ ::ActiveRecord::Base
30
+ end
31
+
32
+ private
33
+
34
+ def database_name
35
+ Rails.configuration.database_configuration.dig(Rails.env, "database")
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tidewave
4
+ module DatabaseAdapters
5
+ class Sequel < DatabaseAdapter
6
+ RESULT_LIMIT = 50
7
+
8
+ def execute_query(query, arguments = [])
9
+ db = ::Sequel::Model.db
10
+
11
+ # Execute the query with arguments
12
+ result = if arguments.any?
13
+ db.fetch(query, *arguments)
14
+ else
15
+ db.fetch(query)
16
+ end
17
+
18
+ # Convert to array of hashes and extract metadata
19
+ rows = result.all
20
+ columns = rows.first&.keys || []
21
+
22
+ # Format the result similar to ActiveRecord
23
+ {
24
+ columns: columns.map(&:to_s),
25
+ rows: rows.first(RESULT_LIMIT).map(&:values),
26
+ row_count: rows.length,
27
+ adapter: db.adapter_scheme.to_s.upcase,
28
+ database: db.opts[:database]
29
+ }
30
+ end
31
+
32
+ def get_base_class
33
+ ::Sequel::Model
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Tidewave::ExceptionsMiddleware
4
+ def initialize(app)
5
+ @app = app
6
+ end
7
+
8
+ def call(env)
9
+ request = ActionDispatch::Request.new(env)
10
+ status, headers, body = @app.call(env)
11
+
12
+ exception = request.get_header("tidewave.exception")
13
+
14
+ if exception
15
+ formatted = format_exception_html(exception, request)
16
+ body, headers = append_body(body, headers, formatted)
17
+ end
18
+
19
+ [ status, headers, body ]
20
+ rescue => error
21
+ Rails.logger.error("Failure in Tidewave::ExceptionsMiddleware, message: #{error.message}")
22
+ raise error
23
+ end
24
+
25
+ private
26
+
27
+ def format_exception_html(exception, request)
28
+ backtrace = Rails.backtrace_cleaner.clean(exception.backtrace)
29
+
30
+ parameters = request_parameters(request)
31
+
32
+ text = exception.class.name.dup
33
+
34
+ if parameters["controller"]
35
+ text << " in #{parameters["controller"].camelize}Controller"
36
+
37
+ if parameters["action"]
38
+ text << "##{parameters["action"]}"
39
+ end
40
+ end
41
+
42
+ text << "\n\n## Message\n\n#{exception.message}"
43
+
44
+ if backtrace.any?
45
+ text << "\n\n## Backtrace\n\n#{backtrace.join("\n")}"
46
+ end
47
+
48
+ text << "\n\n"
49
+
50
+ text << <<~TEXT.chomp
51
+ ## Request info
52
+
53
+ * URI: #{request.base_url + request.path}
54
+ * Query string: #{request.query_string}
55
+
56
+ ## Session
57
+
58
+ #{request.session.to_hash.inspect}
59
+ TEXT
60
+
61
+ %Q(<textarea style="display: none;" data-tidewave-exception-info>#{ERB::Util.html_escape(text)}</textarea>)
62
+ end
63
+
64
+ def request_parameters(request)
65
+ request.parameters
66
+ rescue ActionController::BadRequest
67
+ {}
68
+ end
69
+
70
+ def append_body(body, headers, content)
71
+ new_body = "".dup
72
+
73
+ body.each { |part| new_body << part }
74
+ body.close if body.respond_to?(:close)
75
+
76
+ if position = new_body.rindex("</body>")
77
+ new_body.insert(position, content)
78
+ else
79
+ new_body << content
80
+ end
81
+
82
+ if headers[Rack::CONTENT_LENGTH]
83
+ headers[Rack::CONTENT_LENGTH] = new_body.bytesize.to_s
84
+ end
85
+
86
+ [ [ new_body ], headers ]
87
+ end
88
+ end
@@ -4,9 +4,10 @@ module Tidewave
4
4
  module FileTracker
5
5
  extend self
6
6
 
7
- def project_files(glob_pattern: nil)
8
- args = %w[--git-dir] + [ "#{git_root}/.git", "ls-files", "--cached", "--others" ]
9
- args += glob_pattern ? [ glob_pattern ] : [ "--exclude-standard" ]
7
+ def project_files(glob_pattern: nil, include_ignored: false)
8
+ args = [ "ls-files", "--cached", "--others" ]
9
+ args << "--exclude-standard" unless include_ignored
10
+ args << glob_pattern if glob_pattern
10
11
  `git #{args.join(" ")}`.split("\n")
11
12
  end
12
13
 
@@ -41,11 +42,7 @@ module Tidewave
41
42
  end
42
43
 
43
44
  def file_full_path(path)
44
- File.join(git_root, path)
45
- end
46
-
47
- def git_root
48
- @git_root ||= `git rev-parse --show-toplevel`.strip
45
+ File.expand_path(path, Rails.root)
49
46
  end
50
47
 
51
48
  def validate_path_access!(path, validate_existence: true)
@@ -55,10 +52,14 @@ module Tidewave
55
52
  full_path = file_full_path(path)
56
53
 
57
54
  # Verify the file is within the project directory
58
- raise ArgumentError, "File path must be within the project directory" unless full_path.start_with?(git_root)
55
+ unless full_path.start_with?(Rails.root.to_s + File::SEPARATOR)
56
+ raise ArgumentError, "File path must be within the project directory"
57
+ end
59
58
 
60
59
  # Verify the file exists
61
- raise ArgumentError, "File not found: #{path}" if validate_existence && !File.exist?(full_path)
60
+ if validate_existence && !File.exist?(full_path)
61
+ raise ArgumentError, "File not found: #{path}"
62
+ end
62
63
 
63
64
  true
64
65
  end
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "ipaddr"
5
+ require "fast_mcp"
6
+ require "rack/request"
7
+ require "active_support/core_ext/class"
8
+ require "active_support/core_ext/object/blank"
9
+ require "json"
10
+ require "erb"
11
+
12
+ class Tidewave::Middleware
13
+ TIDEWAVE_ROUTE = "tidewave".freeze
14
+ EMPTY_ROUTE = "empty".freeze
15
+ SSE_ROUTE = "mcp".freeze
16
+ MESSAGES_ROUTE = "mcp/message".freeze
17
+ SHELL_ROUTE = "shell".freeze
18
+
19
+ INVALID_IP = <<~TEXT.freeze
20
+ For security reasons, Tidewave does not accept remote connections by default.
21
+
22
+ If you really want to allow remote connections, set `config.tidewave.allow_remote_access = true`.
23
+ TEXT
24
+
25
+ def initialize(app, config)
26
+ @allow_remote_access = config.allow_remote_access
27
+ @client_url = config.client_url
28
+ @project_name = Rails.application.class.module_parent.name
29
+
30
+ @app = FastMcp.rack_middleware(app,
31
+ name: "tidewave",
32
+ version: Tidewave::VERSION,
33
+ path_prefix: "/" + TIDEWAVE_ROUTE,
34
+ messages_route: MESSAGES_ROUTE,
35
+ sse_route: SSE_ROUTE,
36
+ logger: config.logger || Logger.new(Rails.root.join("log", "tidewave.log")),
37
+ # Rails runs the HostAuthorization in dev, so we skip this
38
+ allowed_origins: [],
39
+ # We validate this one in Tidewave::Middleware
40
+ localhost_only: false
41
+ ) do |server|
42
+ server.filter_tools do |request, tools|
43
+ if request.params["include_fs_tools"] != "true"
44
+ tools.reject { |tool| tool.tags.include?(:file_system_tool) }
45
+ else
46
+ tools
47
+ end
48
+ end
49
+
50
+ server.register_tools(*Tidewave::Tools::Base.descendants)
51
+ end
52
+ end
53
+
54
+ def call(env)
55
+ request = Rack::Request.new(env)
56
+ path = request.path.split("/").reject(&:empty?)
57
+
58
+ if path[0] == TIDEWAVE_ROUTE
59
+ return forbidden(INVALID_IP) unless valid_client_ip?(request)
60
+
61
+ # The MCP routes are handled downstream by FastMCP
62
+ case [ request.request_method, path ]
63
+ when [ "GET", [ TIDEWAVE_ROUTE ] ]
64
+ return home(request)
65
+ when [ "GET", [ TIDEWAVE_ROUTE, EMPTY_ROUTE ] ]
66
+ return empty(request)
67
+ when [ "POST", [ TIDEWAVE_ROUTE, SHELL_ROUTE ] ]
68
+ return shell(request)
69
+ end
70
+ end
71
+
72
+ @app.call(env)
73
+ end
74
+
75
+ private
76
+
77
+ def home(request)
78
+ config = {
79
+ "project_name" => @project_name,
80
+ "framework_type" => "rails",
81
+ "tidewave_version" => Tidewave::VERSION
82
+ }
83
+
84
+ html = <<~HTML
85
+ <html>
86
+ <head>
87
+ <meta charset="UTF-8" />
88
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
89
+ <meta name="tidewave:config" content="#{ERB::Util.html_escape(JSON.generate(config))}" />
90
+ <script type="module" src="#{@client_url}/tc/tc.js"></script>
91
+ </head>
92
+ <body></body>
93
+ </html>
94
+ HTML
95
+
96
+ [ 200, { "Content-Type" => "text/html" }, [ html ] ]
97
+ end
98
+
99
+ def empty(request)
100
+ html = ""
101
+ [ 200, { "Content-Type" => "text/html" }, [ html ] ]
102
+ end
103
+
104
+ def forbidden(message)
105
+ Rails.logger.warn(message)
106
+ [ 403, { "Content-Type" => "text/plain" }, [ message ] ]
107
+ end
108
+
109
+ def shell(request)
110
+ body = request.body.read
111
+ return [ 400, { "Content-Type" => "text/plain" }, [ "Command body is required" ] ] if body.blank?
112
+
113
+ begin
114
+ parsed_body = JSON.parse(body)
115
+ cmd = parsed_body["command"]
116
+ return [ 400, { "Content-Type" => "text/plain" }, [ "Command field is required" ] ] if cmd.blank?
117
+ rescue JSON::ParserError
118
+ return [ 400, { "Content-Type" => "text/plain" }, [ "Invalid JSON in request body" ] ]
119
+ end
120
+
121
+ response = Rack::Response.new
122
+ response.status = 200
123
+ response.headers["Content-Type"] = "text/plain"
124
+
125
+ response.finish do |res|
126
+ begin
127
+ Open3.popen3(*cmd) do |stdin, stdout, stderr, wait_thr|
128
+ stdin.close
129
+
130
+ # Merge stdout and stderr streams
131
+ ios = [ stdout, stderr ]
132
+
133
+ until ios.empty?
134
+ ready = IO.select(ios, nil, nil, 0.1)
135
+ next unless ready
136
+
137
+ ready[0].each do |io|
138
+ begin
139
+ data = io.read_nonblock(4096)
140
+ if data
141
+ # Write binary chunk: type (0 for data) + 4-byte length + data
142
+ chunk = [ 0, data.bytesize ].pack("CN") + data
143
+ res.write(chunk)
144
+ end
145
+ rescue IO::WaitReadable
146
+ # No data available right now
147
+ rescue EOFError
148
+ # Stream ended
149
+ ios.delete(io)
150
+ end
151
+ end
152
+ end
153
+
154
+ # Wait for process to complete and get exit status
155
+ exit_status = wait_thr.value.exitstatus
156
+ status_json = JSON.generate({ status: exit_status })
157
+ # Write binary chunk: type (1 for status) + 4-byte length + JSON data
158
+ chunk = [ 1, status_json.bytesize ].pack("CN") + status_json
159
+ res.write(chunk)
160
+ end
161
+ rescue => e
162
+ error_json = JSON.generate({ status: 213 })
163
+ chunk = [ 1, error_json.bytesize ].pack("CN") + error_json
164
+ res.write(chunk)
165
+ end
166
+ end
167
+ end
168
+
169
+ def valid_client_ip?(request)
170
+ return true if @allow_remote_access
171
+
172
+ ip = request.ip
173
+ return false unless ip
174
+
175
+ addr = IPAddr.new(ip)
176
+
177
+ addr.loopback? ||
178
+ addr == IPAddr.new("127.0.0.1") ||
179
+ addr == IPAddr.new("::1") ||
180
+ addr == IPAddr.new("::ffff:127.0.0.1") # IPv4-mapped IPv6
181
+ end
182
+ end
@@ -1,49 +1,49 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "fast_mcp"
4
3
  require "logger"
5
4
  require "fileutils"
6
5
  require "tidewave/configuration"
7
- require "active_support/core_ext/class"
6
+ require "tidewave/middleware"
7
+ require "tidewave/exceptions_middleware"
8
8
 
9
9
  gem_tools_path = File.expand_path("tools/**/*.rb", __dir__)
10
10
  Dir[gem_tools_path].each { |f| require f }
11
11
 
12
12
  module Tidewave
13
13
  class Railtie < Rails::Railtie
14
- config.tidewave = Tidewave::Configuration.new
15
-
16
- initializer "tidewave.setup_mcp" do |app|
17
- # Prevent MCP server from being mounted if Rails is not running in development mode
18
- raise "For security reasons, Tidewave is only supported in development mode" unless Rails.env.development?
19
-
20
- config = app.config.tidewave
21
-
22
- # Set up MCP server with the host application
23
- FastMcp.mount_in_rails(
24
- app,
25
- name: "tidewave",
26
- version: Tidewave::VERSION,
27
- path_prefix: Tidewave::PATH_PREFIX,
28
- messages_route: Tidewave::MESSAGES_ROUTE,
29
- sse_route: Tidewave::SSE_ROUTE,
30
- logger: config.logger,
31
- allowed_origins: config.allowed_origins,
32
- localhost_only: config.localhost_only,
33
- allowed_ips: config.allowed_ips
34
- ) do |server|
35
- app.config.before_initialize do
36
- server.filter_tools do |request, tools|
37
- if request.params["include_fs_tools"] != "true"
38
- tools.reject { |tool| tool.tags.include?(:file_system_tool) }
39
- else
40
- tools
41
- end
42
- end
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
43
20
 
44
- server.register_tools(*Tidewave::Tools::Base.descendants)
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
45
34
  end
46
35
  end
47
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
48
48
  end
49
49
  end
@@ -33,7 +33,7 @@ class Tidewave::Tools::EditProjectFile < Tidewave::Tools::Base
33
33
  # Check if the file exists within the project root and has been read
34
34
  Tidewave::FileTracker.validate_path_is_editable!(path, atime)
35
35
 
36
- old_content = Tidewave::FileTracker.read_file(path)
36
+ _mtime, old_content = Tidewave::FileTracker.read_file(path)
37
37
 
38
38
  # Ensure old_string is unique within the file
39
39
  scan_result = old_content.scan(old_string)
@@ -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
@@ -21,21 +21,22 @@ class Tidewave::Tools::GetSourceLocation < Tidewave::Tools::Base
21
21
  end
22
22
 
23
23
  def call(reference:)
24
- file_path, line_number = get_source_location(reference)
24
+ file_path, line_number = self.class.get_source_location(reference)
25
25
 
26
26
  if file_path
27
- {
28
- file_path: file_path,
29
- line_number: line_number
30
- }.to_json
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
31
34
  else
32
35
  raise NameError, "could not find source location for #{reference}"
33
36
  end
34
37
  end
35
38
 
36
- private
37
-
38
- def get_source_location(reference)
39
+ def self.get_source_location(reference)
39
40
  constant_path, selector, method_name = reference.rpartition(/\.|#/)
40
41
 
41
42
  # There are no selectors, so the method_name is a constant path
@@ -12,15 +12,15 @@ class Tidewave::Tools::ListProjectFiles < Tidewave::Tools::Base
12
12
  By default, when no arguments are passed, it returns all files in the project that
13
13
  are not ignored by .gitignore.
14
14
 
15
- Optionally, a glob_pattern can be passed to filter this list. When a pattern is passed,
16
- the gitignore check will be skipped.
15
+ Optionally, a glob_pattern can be passed to filter this list.
17
16
  DESC
18
17
 
19
18
  arguments do
20
- optional(:glob_pattern).maybe(:string).description("Optional: a glob pattern to filter the listed files. If a pattern is passed, the .gitignore check will be skipped.")
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
21
  end
22
22
 
23
- def call(glob_pattern: nil)
24
- Tidewave::FileTracker.project_files(glob_pattern: glob_pattern)
23
+ def call(glob_pattern: nil, include_ignored: false)
24
+ Tidewave::FileTracker.project_files(glob_pattern: glob_pattern, include_ignored: include_ignored)
25
25
  end
26
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tidewave
4
- VERSION = "0.1.3"
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
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tidewave
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.3
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yorick Jacquin
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-06-04 00:00:00.000000000 Z
11
+ date: 2025-08-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -64,18 +64,22 @@ files:
64
64
  - config/database.yml
65
65
  - lib/tidewave.rb
66
66
  - lib/tidewave/configuration.rb
67
+ - lib/tidewave/database_adapter.rb
68
+ - lib/tidewave/database_adapters/active_record.rb
69
+ - lib/tidewave/database_adapters/sequel.rb
70
+ - lib/tidewave/exceptions_middleware.rb
67
71
  - lib/tidewave/file_tracker.rb
72
+ - lib/tidewave/middleware.rb
68
73
  - lib/tidewave/railtie.rb
69
74
  - lib/tidewave/tools/base.rb
70
75
  - lib/tidewave/tools/edit_project_file.rb
71
76
  - lib/tidewave/tools/execute_sql_query.rb
77
+ - lib/tidewave/tools/get_docs.rb
72
78
  - lib/tidewave/tools/get_logs.rb
73
79
  - lib/tidewave/tools/get_models.rb
74
80
  - lib/tidewave/tools/get_package_location.rb
75
81
  - lib/tidewave/tools/get_source_location.rb
76
- - lib/tidewave/tools/grep_project_files.rb
77
82
  - lib/tidewave/tools/list_project_files.rb
78
- - lib/tidewave/tools/package_search.rb
79
83
  - lib/tidewave/tools/project_eval.rb
80
84
  - lib/tidewave/tools/read_project_file.rb
81
85
  - lib/tidewave/tools/shell_eval.rb
@@ -1,110 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- class Tidewave::Tools::GrepProjectFiles < Tidewave::Tools::Base
4
- tags :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
- glob = "**/*" if glob.blank?
56
- files = Dir.glob(glob, base: Tidewave::FileTracker.git_root)
57
- results = []
58
- files.each do |file|
59
- full_path = File.join(Tidewave::FileTracker.git_root, file)
60
- next unless File.file?(full_path)
61
-
62
- begin
63
- file_matches = 0
64
- line_number = 0
65
-
66
- File.foreach(full_path) do |line|
67
- line_number += 1
68
-
69
- # Check if line matches pattern with proper case sensitivity
70
- if case_sensitive
71
- next unless line.include?(pattern)
72
- else
73
- next unless line.downcase.include?(pattern.downcase)
74
- end
75
-
76
- results << {
77
- "path" => file,
78
- "line_number" => line_number,
79
- "content" => line.strip
80
- }
81
-
82
- file_matches += 1
83
- # Stop processing this file if we've reached max results for it
84
- break if file_matches >= max_results
85
- end
86
- rescue => e
87
- # Skip files that can't be read (e.g., binary files)
88
- next
89
- end
90
- end
91
-
92
- results.to_json
93
- end
94
-
95
- def format_ripgrep_results(results)
96
- parsed_results = results.split("\n").map(&:strip).reject(&:empty?).map do |line|
97
- JSON.parse(line)
98
- end
99
-
100
- parsed_results.map do |result|
101
- next if result["type"] != "match"
102
-
103
- {
104
- "path" => result.dig("data", "path", "text"),
105
- "line_number" => result.dig("data", "line_number"),
106
- "content" => result.dig("data", "lines", "text").strip
107
- }
108
- end.compact.to_json
109
- end
110
- end
@@ -1,43 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "net/http"
4
- require "uri"
5
- require "json"
6
-
7
- class Tidewave::Tools::PackageSearch < Tidewave::Tools::Base
8
- tool_name "package_search"
9
- description <<~DESCRIPTION
10
- Searches for packages on RubyGems.
11
-
12
- Use this tool if you need to find new packages to add to the project. Before using this tool,
13
- get an overview of the existing dependencies by using the `project_eval` tool and executing
14
- `Gem::Specification.map { |gem| [gem.name, gem.version] }`.
15
-
16
- The results are paginated, with 30 packages per page. Use the `page` parameter to fetch a specific page.
17
- DESCRIPTION
18
-
19
- arguments do
20
- required(:search).filled(:string).description("The search term")
21
- optional(:page).filled(:integer, gt?: 0).description("The page number to fetch. Must be greater than 0. Defaults to 1.")
22
- end
23
-
24
- def call(search:, page: 1)
25
- uri = URI("https://rubygems.org/api/v1/search.json")
26
- uri.query = URI.encode_www_form(query: search, page: page)
27
-
28
- response = Net::HTTP.get_response(uri)
29
-
30
- if response.is_a?(Net::HTTPSuccess)
31
- JSON.parse(response.body).map do |package|
32
- {
33
- name: package["name"],
34
- version: package["version"],
35
- downloads: package["downloads"],
36
- documentation_uri: package["documentation_uri"]
37
- }
38
- end
39
- else
40
- raise "RubyGems API request failed with status code: #{response.code}"
41
- end
42
- end
43
- end