tidewave 0.5.2 → 0.8.1

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: c64d42ec2ee75f35b02997081d24e69fe547d8b9a20a230daeb1d1995a30cd0a
4
- data.tar.gz: 74e1cc3e89cdea2b1d4701990ca3fbe3b4b3a5c828735bef5251c91a1d8c074a
3
+ metadata.gz: 1802cbd495807edd296e6ee4b1a413509159e4b15f5aacbb84a0730bb73e51dc
4
+ data.tar.gz: 263ca6f1ab17b6b2447a1e93cb414483b46ef5842d6408a3b4fb70716ad863c5
5
5
  SHA512:
6
- metadata.gz: a2cf21787f51875cbec78b12b5800c7f03b8d516360fd960110f8717dc762f47e58f4f7a4b243c3f0120e254075f17495e86d6c3538d22dd8abe6ac046d3fc6d
7
- data.tar.gz: 789f139be709d1bc9a0d55b8cb661b9212b3ce2b4fa546d2c644b3a676a6a2843687407e2efdc87c8a0785290c2aab1806693b07698cd71f9fb9e7f130714786
6
+ metadata.gz: 63a8ac146282fc3e3619c8981867c1d45aa939a6d4885f476b5ca995ecc9a7e7a18bb4c1e5bdb055bdace0114318127fed5be7c65191677ad05c9747a24b032b
7
+ data.tar.gz: db620af0482b721917ee736308946624e9d842a250473d72632c11628023cb4c762fa4b21663ab37521007c6bcc7ebf922116545630db821ecc3eb9c0ae7f46f
data/README.md CHANGED
@@ -12,9 +12,7 @@ Your agent will be able to use this MCP server to talk to your running Rails app
12
12
 
13
13
  This MCP server is an open-source component of [Tidewave](https://tidewave.ai), the agentic development environment for Rails and Phoenix.
14
14
 
15
- You can use this project as a standalone MCP server or integrated with the [Tidewave product](https://tidewave.ai).
16
-
17
- To use it as a standalone MCP server, follow the installation instructions below.
15
+ You can use this project as a standalone MCP server or integrated with the [Tidewave product](https://tidewave.ai) by following the instructions below.
18
16
 
19
17
  ## Installation
20
18
 
@@ -44,7 +42,6 @@ We also have specific instructions for:
44
42
  - [Neovim](https://tidewave.hexdocs.pm/mcp_neovim.html)
45
43
  - [OpenCode](https://tidewave.hexdocs.pm/mcp_opencode.html)
46
44
  - [VS Code](https://tidewave.hexdocs.pm/mcp_vscode.html)
47
- - [Zed](https://tidewave.hexdocs.pm/mcp_zed.html)
48
45
  - [Others](https://tidewave.hexdocs.pm/mcp.html)
49
46
 
50
47
  ## Usage
@@ -117,6 +114,10 @@ Also, because it resolves the location from your running app instead of parsing
117
114
 
118
115
  ## Troubleshooting
119
116
 
117
+ ### The Tidewave toolbar is missing
118
+
119
+ This may happen if you are compressing your responses (gzip, brotli, etc) after the Tidewave middleware runs. Use `bin/rails middleware` and make sure Tidewave comes after `Rack::Deflater` or similar. Also look into your browser and terminal logs for any errors.
120
+
120
121
  ### Using multiple hosts/subdomains
121
122
 
122
123
  If you are using multiple hosts/subdomains during development, you must use `*.localhost`, as such domains are considered secure by browsers. Additionally, add the following to `config/initializers/development.rb`:
@@ -135,7 +136,7 @@ The above will allow your application to run embedded within Tidewave across mul
135
136
 
136
137
  ### Content security policy
137
138
 
138
- 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. It also disables the `frame-ancestors` directive.
139
+ 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. It also disables the `frame-ancestors` directive. This is done only in the environments that Tidewave is loadead (development by default).
139
140
 
140
141
  ### Production Environment
141
142
 
@@ -153,7 +154,7 @@ You may configure `tidewave` using the following syntax:
153
154
 
154
155
  The following config is available:
155
156
 
156
- * `allow_remote_access` - Tidewave only allows requests from localhost by default, even if your server listens on other interfaces, for security purposes. Read [our security guidelines for more information and when to allow remote access](https://hexdocs.pm/tidewave/security.html) (if you know what you are doing)
157
+ * `allow_remote_access` - Tidewave only allows requests from localhost by default, even if your server listens on other interfaces, for security purposes. Read [our security guidelines for more information and when to allow remote access](https://tidewave.hexdocs.pm/security.html) (if you know what you are doing)
157
158
 
158
159
  * `logger_middleware` - The logger middleware Tidewave should wrap to silence its own logs
159
160
 
@@ -161,6 +162,8 @@ The following config is available:
161
162
 
162
163
  * `team` - set your Tidewave Team configuration, such as `config.tidewave.team = { id: "my-company" }`
163
164
 
165
+ * `toolbar` - controls whether the Tidewave toolbar is injected into HTML pages. Defaults to `true`
166
+
164
167
  ## Acknowledgements
165
168
 
166
169
  A thank you to Yorick Jacquin for the initial version of this project.
@@ -0,0 +1,283 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action_cable"
4
+ # Action Cable's event loop uses concurrent-ruby without requiring it,
5
+ # so we load it explicitly.
6
+ require "concurrent"
7
+ require "json"
8
+ require "logger"
9
+ require "securerandom"
10
+
11
+ class Tidewave
12
+ # Server side of Tidewave browser control.
13
+ #
14
+ # The WebSocket endpoint is a dedicated Action Cable server, since the user
15
+ # app may not have one, and if it does it likely has auth. Commands/replies
16
+ # are routed between MCP request threads and browser connections over Action
17
+ # Cable pub/sub streams:
18
+ #
19
+ # * tidewave:clients - all registered pages (used for discovery)
20
+ # * tidewave:client:name - the page registered under name
21
+ # * tidewave:reply:ref - replies to a single run_tool command
22
+ #
23
+ # Consequently the routing works across processes whenever the configured
24
+ # cable adapter does, such as "solid_cable", whereas the default "async"
25
+ # adapter is single-process.
26
+ #
27
+ # The pub/sub bus cannot tell whether a stream has any subscribers, so
28
+ # the channel broadcasts an "ack" on the reply stream as soon as it picks
29
+ # up a command, letting the caller fail fast when no client is connected.
30
+ # Similarly, when a page disconnects, its channel broadcasts "disconnected"
31
+ # for every command still awaiting a reply, so the caller does not wait
32
+ # out the full timeout.
33
+ class BrowserControl
34
+ CLIENTS_STREAM = "tidewave:clients"
35
+
36
+ def self.client_stream(name)
37
+ "tidewave:client:#{name}"
38
+ end
39
+
40
+ def self.reply_stream(ref)
41
+ "tidewave:reply:#{ref}"
42
+ end
43
+
44
+ attr_reader :server
45
+
46
+ def initialize(cable:, logger: nil, ack_timeout: 1.0)
47
+ @ack_timeout = ack_timeout
48
+ @server = Server.new(cable: cable, logger: logger || ::Logger.new(IO::NULL))
49
+ end
50
+
51
+ # Rack entrypoint for the WebSocket endpoint.
52
+ def call(env)
53
+ @server.call(env)
54
+ end
55
+
56
+ # Runs the tool against the client owning `sid` and waits for the reply.
57
+ # `timeout_ms` may be nil to wait indefinitely.
58
+ #
59
+ # Returns `[ :ok, reply ]` (the page's response) or `[ :error, reason ]`,
60
+ # where reason is :invalid_sid, :unknown_client, :timeout, or :disconnected.
61
+ def run(sid, tool_name, input, timeout_ms)
62
+ name = parse_sid(sid)
63
+ return [ :error, :invalid_sid ] unless name
64
+
65
+ call_tool(self.class.client_stream(name), tool_name, sid, input, timeout_ms, await_ack: true)
66
+ end
67
+
68
+ # Sends the tool to every connected client and returns the first reply.
69
+ #
70
+ # Used for the discovery handshake (a browser_eval call with no sid).
71
+ # Returns `[ :ok, reply ]` or `[ :error, :timeout ]` when no client
72
+ # answered in time (the bus cannot tell whether anyone is connected).
73
+ def broadcast_run(tool_name, input, timeout_ms)
74
+ call_tool(CLIENTS_STREAM, tool_name, nil, input, timeout_ms, await_ack: false)
75
+ end
76
+
77
+ private
78
+
79
+ def parse_sid(sid)
80
+ name, suffix = sid.split("#", 2)
81
+ name if name && suffix && !name.empty? && !suffix.empty?
82
+ end
83
+
84
+ def call_tool(stream, tool_name, sid, input, timeout_ms, await_ack:)
85
+ # The reply stream is derived from the ref, so it must be unique
86
+ # across processes (unlike a per-process counter).
87
+ ref = SecureRandom.random_number(2**53)
88
+ reply_stream = self.class.reply_stream(ref)
89
+ queue = Queue.new
90
+ on_message = ->(payload) { queue << decode(payload) }
91
+ on_subscribed = -> { queue << :subscribed }
92
+
93
+ @server.pubsub.subscribe(reply_stream, on_message, on_subscribed)
94
+
95
+ # Waiting on the browser can trigger requests back into the app (the
96
+ # page evaluating code issues requests of its own); if such a request
97
+ # needs to reload code, the exclusive reload would wait on this
98
+ # thread's share of the reload interlock, deadlocking until timeout.
99
+ ActiveSupport::Dependencies.interlock.permit_concurrent_loads do
100
+ # Adapters confirm subscriptions asynchronously; broadcasting
101
+ # before the confirmation could lose the reply.
102
+ if queue.pop(timeout: 5) == :subscribed
103
+ message = { "type" => "run_tool", "ref" => ref, "name" => tool_name, "sid" => sid, "input" => input }
104
+ @server.broadcast(stream, message)
105
+ await_reply(queue, timeout_ms && timeout_ms / 1000.0, await_ack)
106
+ else
107
+ [ :error, :timeout ]
108
+ end
109
+ end
110
+ ensure
111
+ @server.pubsub.unsubscribe(reply_stream, on_message)
112
+ end
113
+
114
+ def await_reply(queue, timeout, await_ack)
115
+ deadline = timeout && now + timeout
116
+ ack_deadline = await_ack ? now + @ack_timeout : nil
117
+
118
+ loop do
119
+ wait_until = [ deadline, ack_deadline ].compact.min
120
+ message = queue.pop(timeout: wait_until && [ wait_until - now, 0 ].max)
121
+
122
+ case message.is_a?(Hash) && message["type"]
123
+ when "tool_reply"
124
+ return [ :ok, message["reply"] ]
125
+ when "disconnected"
126
+ return [ :error, :disconnected ]
127
+ when "ack"
128
+ ack_deadline = nil
129
+ else
130
+ # No ack means no connected client picked the command up,
131
+ # so that client is likely already disconnected.
132
+ return [ :error, :unknown_client ] if ack_deadline && now >= ack_deadline
133
+ return [ :error, :timeout ] if deadline && now >= deadline
134
+ end
135
+ end
136
+ end
137
+
138
+ def decode(payload)
139
+ JSON.parse(payload)
140
+ rescue JSON::ParserError
141
+ nil
142
+ end
143
+
144
+ def now
145
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
146
+ end
147
+
148
+ class Server < ActionCable::Server::Base
149
+ # Registry of client names owned by connections in this process,
150
+ # backing the "hello" name-uniqueness check.
151
+ attr_reader :client_registry
152
+
153
+ def initialize(cable:, logger:)
154
+ config = ActionCable::Server::Configuration.new
155
+ config.cable = cable
156
+ config.connection_class = -> { Tidewave::BrowserControl::Connection }
157
+ # The origin is validated by the Tidewave middleware before the
158
+ # request reaches this server.
159
+ config.disable_request_forgery_protection = true
160
+ config.logger = logger
161
+ super(config: config)
162
+ @client_registry = ClientRegistry.new
163
+ end
164
+ end
165
+
166
+ class Connection < ActionCable::Connection::Base
167
+ end
168
+
169
+ # Handles a single control page connection.
170
+ class Channel < ActionCable::Channel::Base
171
+ def initialize(connection, identifier, params = {})
172
+ super
173
+ @mutex = Mutex.new
174
+ @name = nil
175
+ @pending_refs = {}
176
+ end
177
+
178
+ def receive(data)
179
+ case data["type"]
180
+ when "hello"
181
+ hello(data["name"])
182
+ when "tool_reply"
183
+ tool_reply(data)
184
+ end
185
+
186
+ # "ping" and unknown messages are ignored; the page pings to keep
187
+ # the socket alive through proxies
188
+ end
189
+
190
+ def unsubscribed
191
+ server.client_registry.unregister(@name, self) if @name
192
+
193
+ refs = @mutex.synchronize do
194
+ @pending_refs.keys.tap { @pending_refs.clear }
195
+ end
196
+
197
+ refs.each do |ref|
198
+ server.broadcast(BrowserControl.reply_stream(ref), { "type" => "disconnected" })
199
+ end
200
+ end
201
+
202
+ private
203
+
204
+ def hello(name)
205
+ return unless name.is_a?(String)
206
+
207
+ if server.client_registry.register(name, self)
208
+ @name = name
209
+
210
+ # Commands, including broadcasts, are only delivered to pages
211
+ # registered under a name.
212
+ stream_from(BrowserControl.client_stream(name), coder: ActiveSupport::JSON) do |message|
213
+ handle_command(message)
214
+ end
215
+
216
+ stream_from(CLIENTS_STREAM, coder: ActiveSupport::JSON) do |message|
217
+ handle_command(message)
218
+ end
219
+
220
+ transmit({ "type" => "hello_ok", "name" => name })
221
+ else
222
+ transmit({ "type" => "hello_error", "reason" => "name_taken" })
223
+ end
224
+ end
225
+
226
+ def handle_command(message)
227
+ return unless message.is_a?(Hash) && message["type"] == "run_tool"
228
+
229
+ ref = message["ref"]
230
+ return unless ref.is_a?(Integer)
231
+
232
+ @mutex.synchronize { @pending_refs[ref] = true }
233
+ # The ack tells the caller the command reached a connected page
234
+ # (the pub/sub bus cannot tell whether anyone is subscribed).
235
+ server.broadcast(BrowserControl.reply_stream(ref), { "type" => "ack" })
236
+ transmit(message)
237
+ end
238
+
239
+ def tool_reply(data)
240
+ ref = data["ref"]
241
+ return unless @mutex.synchronize { @pending_refs.delete(ref) }
242
+
243
+ server.broadcast(BrowserControl.reply_stream(ref), data)
244
+ end
245
+
246
+ def server
247
+ connection.server
248
+ end
249
+ end
250
+
251
+ class ClientRegistry
252
+ def initialize
253
+ @mutex = Mutex.new
254
+ @clients = {}
255
+ end
256
+
257
+ # Registers `owner` under `name`. Returns false when a different live
258
+ # owner already holds the name.
259
+ #
260
+ # The registry is per-process, so with a multi-process cable adapter
261
+ # the uniqueness check is best-effort (client-generated names carry
262
+ # enough entropy for collisions to be negligible).
263
+ def register(name, owner)
264
+ @mutex.synchronize do
265
+ current = @clients[name]
266
+
267
+ if current.nil? || current.equal?(owner)
268
+ @clients[name] = owner
269
+ true
270
+ else
271
+ false
272
+ end
273
+ end
274
+ end
275
+
276
+ def unregister(name, owner)
277
+ @mutex.synchronize do
278
+ @clients.delete(name) if @clients[name].equal?(owner)
279
+ end
280
+ end
281
+ end
282
+ end
283
+ end
@@ -2,17 +2,22 @@
2
2
 
3
3
  class Tidewave
4
4
  class Configuration
5
- attr_accessor :logger, :allow_remote_access, :preferred_orm, :dev, :client_url, :team, :logger_middleware
5
+ attr_accessor :logger, :allow_remote_access, :cable, :preferred_orm, :dev, :client_url, :team, :logger_middleware, :toolbar
6
6
 
7
7
  def initialize
8
8
  # Rails has a hosts middleware which already checks for this
9
9
  @allow_remote_access = true
10
+ # Cable adapter configuration for the browser control WebSocket.
11
+ # Defaults to the app's config/cable.yml (or the in-process "async"
12
+ # adapter when there is none).
13
+ @cable = nil
10
14
  @logger = nil
11
15
  @preferred_orm = :active_record
12
16
  @dev = false
13
17
  @client_url = "https://tidewave.ai"
14
18
  @team = {}
15
19
  @logger_middleware = nil
20
+ @toolbar = true
16
21
  end
17
22
  end
18
23
  end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Tidewave
4
+ module MagicBytes
5
+ module_function
6
+
7
+ def type(bytes)
8
+ case bytes
9
+ when /\A\xFF\xD8\xFF/n
10
+ :jpg
11
+ when /\A\x89PNG\r\n\x1A\n/n
12
+ :png
13
+ when /\A\x1A\x45\xDF\xA3/n
14
+ bytes.include?("webm") ? :webm : :unknown
15
+ else
16
+ :unknown
17
+ end
18
+ end
19
+ end
20
+ end
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "logger"
4
+ require "uri"
5
+ require "tidewave/browser_control"
4
6
  require "tidewave/configuration"
5
7
  require "tidewave/exceptions_middleware"
6
8
  require "tidewave/quiet_requests_middleware"
@@ -9,6 +11,21 @@ class Tidewave
9
11
  class Railtie < Rails::Railtie
10
12
  config.tidewave = Tidewave::Configuration.new()
11
13
 
14
+ def self.cable_config(app)
15
+ cable =
16
+ begin
17
+ if app.root.join("config", "cable.yml").exist?
18
+ app.config_for(:cable)&.to_h&.deep_stringify_keys
19
+ end
20
+ rescue StandardError => error
21
+ Rails.logger&.warn("Tidewave could not load config/cable.yml: #{error.message}")
22
+ nil
23
+ end
24
+
25
+ # Default to the in-process async adapter, as Rails does in development.
26
+ cable.presence || { "adapter" => "async" }
27
+ end
28
+
12
29
  initializer "tidewave.setup" do |app|
13
30
  unless app.config.enable_reloading
14
31
  raise "For security reasons, Tidewave is only supported in environments where config.enable_reloading is true (typically development)"
@@ -20,10 +37,12 @@ class Tidewave
20
37
  ActionDispatch::Callbacks,
21
38
  Tidewave,
22
39
  allow_remote_access: tidewave_config.allow_remote_access,
40
+ browser_control: Tidewave::BrowserControl.new(cable: tidewave_config.cable || Railtie.cable_config(app)),
23
41
  client_url: tidewave_config.client_url,
24
42
  framework_type: "rails",
25
43
  project_name: app.class.module_parent.name,
26
44
  team: tidewave_config.team,
45
+ toolbar: tidewave_config.toolbar,
27
46
  logger: tidewave_config.logger || Rails.logger,
28
47
  root: Rails.root,
29
48
  log_file: Rails.root.join("log", "#{Rails.env}.log"),
@@ -35,11 +54,21 @@ class Tidewave
35
54
  # If the user configured CSP, we need to alter it in dev
36
55
  # to allow TC to run browser_eval.
37
56
  app.config.content_security_policy.try do |content_security_policy|
38
- content_security_policy.directives["script-src"].try do |script_src|
57
+ directives = content_security_policy.directives
58
+ script_src = directives["script-src"] || directives["default-src"]&.dup
59
+ client_origin = URI.parse(tidewave_config.client_url.to_s).origin
60
+
61
+ script_src.try do
39
62
  script_src << "'unsafe-eval'" unless script_src.include?("'unsafe-eval'")
63
+ script_src << client_origin unless script_src.include?(client_origin)
64
+ directives["script-src"] = script_src
65
+ end
66
+
67
+ directives["script-src-elem"].try do |script_src_elem|
68
+ script_src_elem << client_origin unless script_src_elem.include?(client_origin)
40
69
  end
41
70
 
42
- content_security_policy.directives.delete("frame-ancestors")
71
+ directives.delete("frame-ancestors")
43
72
  end
44
73
  end
45
74
  end
data/lib/tidewave/tool.rb CHANGED
@@ -24,7 +24,7 @@ class Tidewave
24
24
  raise NotImplementedError, "#{self.class} must implement #call"
25
25
  end
26
26
 
27
- def validate_and_call(arguments)
27
+ def validate_and_call(arguments, context = {})
28
28
  arguments ||= {}
29
29
 
30
30
  unless arguments.is_a?(Hash)
@@ -36,7 +36,14 @@ class Tidewave
36
36
  # `minLength`, `maxLength`, `enum`, and `pattern` remain descriptive until
37
37
  # Tidewave grows broader schema support.
38
38
  validate_schema(arguments, definition.fetch("inputSchema", {}))
39
- call(arguments)
39
+
40
+ # Tools opt into request context (such as the request URL) by
41
+ # accepting a second argument.
42
+ if method(:call).arity.abs >= 2
43
+ call(arguments, context)
44
+ else
45
+ call(arguments)
46
+ end
40
47
  end
41
48
 
42
49
  private
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Tidewave::Tools::BrowserEval < Tidewave::Tool
4
+ DESCRIPTION = <<~DESCRIPTION
5
+ Runs JavaScript in a real browser to interact with the application.
6
+
7
+ You MUST use "help" action first to learn the full API.
8
+ DESCRIPTION
9
+
10
+ BROADCAST_TIMEOUT_MS = 5_000
11
+
12
+ def initialize(options = {})
13
+ super
14
+ @browser_control = options[:browser_control]
15
+ end
16
+
17
+ def browser_tool?
18
+ true
19
+ end
20
+
21
+ def definition
22
+ return nil unless @browser_control
23
+
24
+ {
25
+ "name" => "browser_eval",
26
+ "description" => DESCRIPTION,
27
+ "inputSchema" => {
28
+ "type" => "object",
29
+ "properties" => {
30
+ "action" => {
31
+ "type" => "string"
32
+ },
33
+ "sid" => {
34
+ "description" => 'The session to target, e.g. "nice-cactus#1".',
35
+ "type" => "string"
36
+ },
37
+ "args" => {
38
+ "description" => 'Parameters for the action, as documented by "help".',
39
+ "type" => "object",
40
+ "additionalProperties" => true
41
+ }
42
+ },
43
+ "required" => [ "action" ]
44
+ }
45
+ }
46
+ end
47
+
48
+ def call(arguments, context = {})
49
+ url = context[:url]
50
+ sid = arguments["sid"]
51
+
52
+ if sid.is_a?(String) && !sid.empty?
53
+ result = @browser_control.run(sid, "browser_eval", arguments, nil)
54
+ direct_result(result, sid, url)
55
+ else
56
+ # The broadcast case is only expected to run for initial discovery.
57
+ # We can safely retry once if the first attempt times out.
58
+ result = @browser_control.broadcast_run("browser_eval", arguments, BROADCAST_TIMEOUT_MS)
59
+ result = @browser_control.broadcast_run("browser_eval", arguments, BROADCAST_TIMEOUT_MS) if result == [ :error, :timeout ]
60
+ broadcast_result(result, url)
61
+ end
62
+ end
63
+
64
+ private
65
+
66
+ def direct_result(result, sid, url)
67
+ status, value = result
68
+ return value.fetch("result") if status == :ok
69
+
70
+ case value
71
+ when :invalid_sid
72
+ error_result(%(Invalid sid "#{sid}". A sid looks like "nice-cactus#1".))
73
+ when :unknown_client
74
+ error_result(
75
+ "No connected browser owns sid \"#{sid}\". It may have disconnected — " \
76
+ 'call browser_eval({"action": "new-session"}) to start a new one.'
77
+ )
78
+ when :timeout
79
+ error_result("browser_eval timed out waiting for the browser to respond.")
80
+ when :disconnected
81
+ error_result("The browser disconnected before responding. #{open_message(url)}")
82
+ end
83
+ end
84
+
85
+ def broadcast_result(result, url)
86
+ status, value = result
87
+ return value.fetch("result") if status == :ok
88
+
89
+ error_result("No browser is connected to the Tidewave control page. #{open_message(url)}")
90
+ end
91
+
92
+ def open_message(url)
93
+ "Use the `open` command (or similar) to open #{url}/tidewave in the browser and try again"
94
+ end
95
+
96
+ def error_result(text)
97
+ {
98
+ "content" => [ { "type" => "text", "text" => text } ],
99
+ "isError" => true
100
+ }
101
+ end
102
+ end
@@ -48,6 +48,17 @@ class Tidewave::Tools::ExecuteSqlQuery < Tidewave::Tool
48
48
  def call(arguments_hash)
49
49
  query = arguments_hash.fetch("query")
50
50
  arguments = arguments_hash.fetch("arguments", [])
51
- @database_adapter.execute_query(query, arguments)
51
+ result = @database_adapter.execute_query(query, arguments)
52
+
53
+ preamble = if result[:row_count] > result[:rows].length
54
+ <<~TEXT
55
+ Query returned #{result[:row_count]} rows. Only the first #{result[:rows].length} rows are included in the result. Use your database's pagination syntax, such as LIMIT + OFFSET, to show more rows if applicable.
56
+
57
+ TEXT
58
+ else
59
+ ""
60
+ end
61
+
62
+ preamble + result.inspect
52
63
  end
53
64
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Tidewave
4
- VERSION = "0.5.2"
4
+ VERSION = "0.8.1"
5
5
  end
data/lib/tidewave.rb CHANGED
@@ -1,11 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "fileutils"
4
+ require "cgi"
3
5
  require "ipaddr"
4
6
  require "json"
7
+ require "pathname"
5
8
  require "rack/request"
6
9
  require "tidewave/version"
7
10
  require "tidewave/tool"
8
11
  require "tidewave/database_adapter"
12
+ require "tidewave/magic_bytes"
9
13
  require "tidewave/railtie" if defined?(Rails::Railtie)
10
14
 
11
15
  class Tidewave
@@ -25,10 +29,57 @@ Dir[gem_tools_path].sort.each do |file|
25
29
  end
26
30
 
27
31
  class Tidewave
32
+ class ToolbarBody
33
+ def initialize(body, toolbar)
34
+ @body = body
35
+ @toolbar = toolbar
36
+ @closed = false
37
+ end
38
+
39
+ def each
40
+ return enum_for(:each) unless block_given?
41
+
42
+ pending = +""
43
+ injected = false
44
+
45
+ @body.each do |part|
46
+ if injected
47
+ yield part
48
+ else
49
+ pending << part
50
+
51
+ if closing_head = pending.downcase.index("</head>")
52
+ toolbar = @toolbar.dup.force_encoding(pending.encoding)
53
+ output = pending.insert(closing_head, toolbar)
54
+ pending = nil
55
+ injected = true
56
+ yield output
57
+ end
58
+ end
59
+ end
60
+
61
+ yield pending unless injected || pending.empty?
62
+ end
63
+
64
+ def close
65
+ return if @closed
66
+
67
+ @closed = true
68
+ @body.close if @body.respond_to?(:close)
69
+ end
70
+ end
71
+
28
72
  TIDEWAVE_ROUTE = "tidewave".freeze
29
73
  MCP_ROUTE = "mcp".freeze
30
74
  CONFIG_ROUTE = "config".freeze
75
+ CONNECT_ROUTE = "connect".freeze
76
+ UPLOAD_ROUTE = "upload".freeze
77
+ WS_ROUTE = "ws".freeze
31
78
  PROTOCOL_VERSION = "2025-03-26".freeze
79
+ MAX_UPLOAD_SIZE = 10_000_000
80
+ ALLOWED_UPLOAD_CONTENT_TYPES = [ "image/png", "image/jpeg", "video/webm" ].freeze
81
+ ALLOWED_UPLOAD_TYPES = [ "screenshot", "recording" ].freeze
82
+ TMP_DIR = "tmp".freeze
32
83
 
33
84
  INVALID_IP = <<~TEXT.freeze
34
85
  For security reasons, Tidewave does not accept remote connections by default.
@@ -36,13 +87,22 @@ class Tidewave
36
87
  If you really want to allow remote connections, configure Tidewave with the `allow_remote_access: true` option
37
88
  TEXT
38
89
 
90
+ INVALID_FETCH_SITE = "For security reasons, Tidewave only accepts requests from the same origin your web app is running on.".freeze
39
91
  INVALID_ORIGIN = "For security reasons, Tidewave does not accept requests with an origin header for this endpoint.".freeze
92
+ INVALID_UPLOAD = "Bad Request: missing or invalid file parameter".freeze
93
+ ENCODED_HTML_WARNING = <<~TEXT.freeze
94
+ Tidewave could not inject the toolbar because the HTML response is encoded.
95
+
96
+ If you use Rack::Deflater or another compression middleware, place it before Tidewave in the middleware stack.
97
+ TEXT
40
98
 
41
99
  DEFAULT_OPTIONS = {
42
100
  allow_remote_access: false,
101
+ browser_control: nil,
43
102
  client_url: "https://tidewave.ai",
44
103
  framework_type: "rack",
45
- team: {}
104
+ team: {},
105
+ toolbar: true
46
106
  }.freeze
47
107
 
48
108
  def initialize(app, options = {})
@@ -51,6 +111,8 @@ class Tidewave
51
111
  raise ArgumentError, "project_name is required" if @options[:project_name].to_s.empty?
52
112
 
53
113
  @logger = @options[:logger]
114
+ @root = @options[:root] ? Pathname.new(@options[:root].to_s) : Pathname.pwd
115
+ @browser_control = @options[:browser_control]
54
116
  @tools = build_tool_registry
55
117
  end
56
118
 
@@ -60,24 +122,34 @@ class Tidewave
60
122
 
61
123
  if path[0] == TIDEWAVE_ROUTE
62
124
  return forbidden(INVALID_IP) unless valid_client_ip?(request)
63
- if request.get_header("HTTP_ORIGIN") && !origin_allowed_path?(path)
64
- return forbidden(INVALID_ORIGIN)
65
- end
125
+
126
+ origin_error = check_origin(request, path)
127
+ return origin_error if origin_error
66
128
 
67
129
  case [ request.request_method, path ]
68
130
  when [ "GET", [ TIDEWAVE_ROUTE ] ]
69
131
  home_endpoint(request)
132
+ when [ "GET", [ TIDEWAVE_ROUTE, WS_ROUTE ] ]
133
+ unless @browser_control
134
+ raise "this route is currently only supported for Rails"
135
+ end
136
+
137
+ @browser_control.call(request.env)
138
+ when [ "GET", [ TIDEWAVE_ROUTE, CONNECT_ROUTE ] ]
139
+ app_endpoint(request)
70
140
  when [ "GET", [ TIDEWAVE_ROUTE, CONFIG_ROUTE ] ]
71
141
  config_endpoint(request)
72
142
  when [ "POST", [ TIDEWAVE_ROUTE, MCP_ROUTE ] ]
73
143
  mcp_endpoint(request)
144
+ when [ "POST", [ TIDEWAVE_ROUTE, UPLOAD_ROUTE ] ]
145
+ upload_endpoint(request)
74
146
  else
75
147
  # The MCP Streamable HTTP transport requires the MCP endpoint to answer
76
148
  # non-POST methods with 405 (GET without SSE support, DELETE, etc.)
77
149
  path == [ TIDEWAVE_ROUTE, MCP_ROUTE ] ? method_not_allowed() : not_found()
78
150
  end
79
151
  else
80
- strip_x_frame_options(@app.call(env))
152
+ inject_toolbar(request, strip_x_frame_options(@app.call(env)))
81
153
  end
82
154
  end
83
155
 
@@ -106,17 +178,77 @@ class Tidewave
106
178
  [ 200, response_headers("text/html", body), [ body ] ]
107
179
  end
108
180
 
181
+ def app_endpoint(request)
182
+ client_url = @options[:client_url].to_s.sub(%r{/\z}, "")
183
+ body = <<~HTML
184
+ <!DOCTYPE html>
185
+ <html>
186
+ <head>
187
+ <meta charset="UTF-8" />
188
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
189
+ #{config_meta_tag(request)}
190
+ <script type="module" src="#{client_url}/tc/control.js"></script>
191
+ </head>
192
+ <body></body>
193
+ </html>
194
+ HTML
195
+
196
+ headers = response_headers("text/html", body)
197
+ headers["content-security-policy"] = "base-uri 'self'; frame-ancestors 'self';"
198
+ [ 200, headers, [ body ] ]
199
+ end
200
+
109
201
  def config_endpoint(request)
110
202
  json_response(config_data(request), headers: { "access-control-allow-origin" => "*" })
111
203
  end
112
204
 
205
+ # Returns a 403 response when the request is not allowed for the given
206
+ # path, nil otherwise.
207
+ def check_origin(request, path)
208
+ case path
209
+ when [ TIDEWAVE_ROUTE ], [ TIDEWAVE_ROUTE, CONFIG_ROUTE ]
210
+ # Allow any origin:
211
+ # * /tidewave is loaded by IDE in a cross-origin iframe
212
+ # * /config contains metadata for discovery
213
+ nil
214
+ when [ TIDEWAVE_ROUTE, CONNECT_ROUTE ], [ TIDEWAVE_ROUTE, WS_ROUTE ], [ TIDEWAVE_ROUTE, UPLOAD_ROUTE ]
215
+ # Browser-facing routes are subject to the fetch metadata policy
216
+ forbidden(INVALID_FETCH_SITE) unless allowed_fetch_site?(request)
217
+ else
218
+ # The MCP endpoint (and everything else) is meant for MCP clients
219
+ # and never the browser, so we reject even same-origin browser
220
+ # requests (browsers set the origin header on all POST requests)
221
+ forbidden(INVALID_ORIGIN) unless request.get_header("HTTP_ORIGIN").nil?
222
+ end
223
+ end
224
+
225
+ def allowed_fetch_site?(request)
226
+ # Note that these checks do not prevent DNS rebinding, but Rails
227
+ # already guards against it through the HostAuthorization middleware.
228
+
229
+ fetch_site = request.get_header("HTTP_SEC_FETCH_SITE")
230
+ fetch_mode = request.get_header("HTTP_SEC_FETCH_MODE")
231
+ fetch_dest = request.get_header("HTTP_SEC_FETCH_DEST")
232
+
233
+ # Same-origin request or user-originated request.
234
+ return true if fetch_site.nil? || [ "same-origin", "none" ].include?(fetch_site)
235
+
236
+ # Allow regular cross-site top-level navigations, such as following
237
+ # a link to the /tidewave/connect page. Form submissions are
238
+ # navigations too, hence the GET check.
239
+ return true if request.get? && fetch_mode == "navigate" && fetch_dest == "document"
240
+
241
+ false
242
+ end
243
+
113
244
  def mcp_endpoint(request)
114
245
  message = JSON.parse(request.body.read)
246
+ context = mcp_context(request)
115
247
 
116
248
  if message.is_a?(Array)
117
- handle_mcp_batch(message)
249
+ handle_mcp_batch(message, context)
118
250
  else
119
- handle_mcp_single(message)
251
+ handle_mcp_single(message, context)
120
252
  end
121
253
  rescue JSON::ParserError
122
254
  jsonrpc_error_response(nil, -32700, "Parse error", status: 400)
@@ -125,26 +257,36 @@ class Tidewave
125
257
  jsonrpc_error_response(nil, -32603, "Internal error")
126
258
  end
127
259
 
128
- def handle_mcp_single(message)
260
+ def mcp_context(request)
261
+ tools = @tools
262
+
263
+ if request.GET["include_browser_tools"] == "false"
264
+ tools = tools.reject { |_name, tool| tool.respond_to?(:browser_tool?) && tool.browser_tool? }
265
+ end
266
+
267
+ { tools: tools, url: request.base_url }
268
+ end
269
+
270
+ def handle_mcp_single(message, context)
129
271
  validation_error = validate_jsonrpc_message(message)
130
272
  return jsonrpc_error_response(nil, -32600, validation_error, status: 400) if validation_error
131
273
 
132
- response = handle_mcp_message(message)
274
+ response = handle_mcp_message(message, context)
133
275
  response.nil? ? accepted_response : json_response(response)
134
276
  end
135
277
 
136
- def handle_mcp_batch(messages)
278
+ def handle_mcp_batch(messages, context)
137
279
  return jsonrpc_error_response(nil, -32600, "Invalid Request", status: 400) if messages.empty?
138
280
 
139
- responses = messages.map { |message| handle_mcp_batch_message(message) }.compact
281
+ responses = messages.map { |message| handle_mcp_batch_message(message, context) }.compact
140
282
  responses.empty? ? accepted_response : json_response(responses)
141
283
  end
142
284
 
143
- def handle_mcp_batch_message(message)
285
+ def handle_mcp_batch_message(message, context)
144
286
  validation_error = validate_jsonrpc_message(message)
145
287
  return jsonrpc_error_response_body(nil, -32600, validation_error) if validation_error
146
288
 
147
- handle_mcp_message(message)
289
+ handle_mcp_message(message, context)
148
290
  end
149
291
 
150
292
  def config_data(request)
@@ -154,8 +296,95 @@ class Tidewave
154
296
  "orm_adapter" => @options[:orm_adapter],
155
297
  "team" => @options[:team] || {},
156
298
  "tidewave_version" => VERSION,
157
- "local_port" => local_port(request)
299
+ "local_port" => local_port(request),
300
+ "tmp_dir" => TMP_DIR
301
+ }
302
+ end
303
+
304
+ def inject_toolbar(request, response)
305
+ status, headers, body = response
306
+ return response if @options[:toolbar] == false || !html_response?(headers)
307
+
308
+ if encoded_response?(headers)
309
+ warn_encoded_html
310
+ return response
311
+ end
312
+
313
+ return response unless body.respond_to?(:each)
314
+
315
+ delete_response_header(headers, "content-length")
316
+ delete_response_header(headers, "etag")
317
+ [ status, headers, ToolbarBody.new(body, toolbar_html(request)) ]
318
+ end
319
+
320
+ def html_response?(headers)
321
+ content_types = Array(response_header(headers, "content-type"))
322
+
323
+ content_types.any? { |content_type| content_type.to_s.downcase.start_with?("text/html") }
324
+ end
325
+
326
+ def encoded_response?(headers)
327
+ Array(response_header(headers, "content-encoding")).any? do |content_encoding|
328
+ content_encoding.to_s.split(",").any? do |encoding|
329
+ !encoding.strip.empty? && encoding.strip.downcase != "identity"
330
+ end
331
+ end
332
+ end
333
+
334
+ def response_header(headers, name)
335
+ key = headers.keys.find { |header| header.downcase == name }
336
+ headers[key] if key
337
+ end
338
+
339
+ def delete_response_header(headers, name)
340
+ headers.delete_if { |header, _value| header.downcase == name }
341
+ end
342
+
343
+ def warn_encoded_html
344
+ return if @warned_encoded_html
345
+
346
+ @warned_encoded_html = true
347
+ @logger&.warn(ENCODED_HTML_WARNING)
348
+ end
349
+
350
+ def toolbar_html(request)
351
+ client_url = @options[:client_url].to_s.sub(%r{/\z}, "")
352
+
353
+ <<~HTML
354
+ #{config_meta_tag(request)}
355
+ <script async type="module" src="#{client_url}/tc/toolbar.js"></script>
356
+ HTML
357
+ end
358
+
359
+ def config_meta_tag(request)
360
+ payload = {
361
+ "tidewave" => config_data(request),
362
+ "root" => @root.to_s,
363
+ "wsl_distro" => ENV["WSL_DISTRO_NAME"],
364
+ "framework" => {}
158
365
  }
366
+
367
+ %(<meta name="tidewave:config" content="#{CGI.escapeHTML(JSON.generate(payload))}" />)
368
+ end
369
+
370
+ def upload_endpoint(request)
371
+ return text_response(400, INVALID_UPLOAD) if upload_too_large?(request)
372
+
373
+ params = request.POST
374
+ type = params["type"]
375
+ upload = normalize_upload(params["file"])
376
+
377
+ unless ALLOWED_UPLOAD_TYPES.include?(type) && allowed_upload?(upload)
378
+ return text_response(400, INVALID_UPLOAD)
379
+ end
380
+
381
+ FileUtils.mkdir_p(upload_dir(type))
382
+ destination = upload_path(type, upload[:filename])
383
+ FileUtils.cp(upload[:path], destination)
384
+
385
+ json_response({ "status" => "ok", "path" => relative_path_from_root(destination) })
386
+ rescue ArgumentError
387
+ text_response(400, INVALID_UPLOAD)
159
388
  end
160
389
 
161
390
  def json_response(payload, status: 200, headers: {})
@@ -196,10 +425,6 @@ class Tidewave
196
425
  }
197
426
  end
198
427
 
199
- def origin_allowed_path?(path)
200
- path == [ TIDEWAVE_ROUTE ] || path == [ TIDEWAVE_ROUTE, CONFIG_ROUTE ]
201
- end
202
-
203
428
  def local_port(request)
204
429
  sock = request.env["puma.socket"]
205
430
  return unless sock
@@ -220,6 +445,67 @@ class Tidewave
220
445
  false
221
446
  end
222
447
 
448
+ def upload_too_large?(request)
449
+ request.content_length && request.content_length.to_i > MAX_UPLOAD_SIZE
450
+ end
451
+
452
+ def normalize_upload(upload)
453
+ case upload
454
+ when Hash
455
+ tempfile = upload[:tempfile] || upload["tempfile"]
456
+ {
457
+ filename: upload[:filename] || upload["filename"],
458
+ content_type: upload[:type] || upload["type"],
459
+ path: tempfile&.path
460
+ }
461
+ else
462
+ return {} unless upload.respond_to?(:original_filename) && upload.respond_to?(:content_type)
463
+
464
+ {
465
+ filename: upload.original_filename,
466
+ content_type: upload.content_type,
467
+ path: upload.tempfile&.path
468
+ }
469
+ end
470
+ end
471
+
472
+ def allowed_upload?(upload)
473
+ ALLOWED_UPLOAD_CONTENT_TYPES.include?(upload[:content_type].to_s.split(";").first) &&
474
+ upload[:path] &&
475
+ Tidewave::MagicBytes.type(File.binread(upload[:path], 128)) != :unknown
476
+ end
477
+
478
+ def upload_dir(type)
479
+ @root.join(TMP_DIR, "tidewave", folder_for_upload_type(type)).to_s
480
+ end
481
+
482
+ def upload_path(type, filename)
483
+ filename = filename.to_s
484
+
485
+ unless filename.match?(/\A[A-Za-z0-9_.-]+\z/) && !filename.include?("..")
486
+ raise ArgumentError, "filename must only contain numbers, letters, hyphens, and underscores: #{filename}"
487
+ end
488
+
489
+ unless [ ".png", ".jpg", ".jpeg", ".webm" ].include?(File.extname(filename).downcase)
490
+ raise ArgumentError, "filename must have a valid extension (.png, .jpg, .jpeg, .webm): #{filename}"
491
+ end
492
+
493
+ File.join(upload_dir(type), filename)
494
+ end
495
+
496
+ def folder_for_upload_type(type)
497
+ case type
498
+ when "screenshot"
499
+ "screenshots"
500
+ when "recording"
501
+ "recordings"
502
+ end
503
+ end
504
+
505
+ def relative_path_from_root(path)
506
+ Pathname.new(path).relative_path_from(@root).to_s
507
+ end
508
+
223
509
  def validate_jsonrpc_message(message)
224
510
  return "Message must be a JSON object" unless message.is_a?(Hash)
225
511
  return "Invalid JSON-RPC version" unless message["jsonrpc"] == "2.0"
@@ -237,7 +523,7 @@ class Tidewave
237
523
  # Returns the JSON-RPC response for a request, or nil for messages that
238
524
  # must not be replied to (notifications and client-sent responses), which
239
525
  # the transport acknowledges with 202 Accepted.
240
- def handle_mcp_message(message)
526
+ def handle_mcp_message(message, context)
241
527
  return nil unless message.key?("method") && message.key?("id")
242
528
 
243
529
  method = message["method"]
@@ -248,11 +534,11 @@ class Tidewave
248
534
  when "ping"
249
535
  jsonrpc_success_response_body(request_id, {})
250
536
  when "initialize"
251
- handle_initialize(request_id, params)
537
+ handle_initialize(request_id, params, context)
252
538
  when "tools/list"
253
- jsonrpc_success_response_body(request_id, { "tools" => tool_definitions })
539
+ jsonrpc_success_response_body(request_id, { "tools" => tool_definitions(context) })
254
540
  when "tools/call"
255
- handle_tool_call(request_id, params)
541
+ handle_tool_call(request_id, params, context)
256
542
  when "prompts/list"
257
543
  jsonrpc_success_response_body(request_id, { "prompts" => [] })
258
544
  when "resources/list"
@@ -272,7 +558,7 @@ class Tidewave
272
558
  end
273
559
  end
274
560
 
275
- def handle_initialize(request_id, params)
561
+ def handle_initialize(request_id, params, context)
276
562
  client_version = params["protocolVersion"]
277
563
  return jsonrpc_error_response_body(request_id, -32602, "Protocol version is required") if client_version.nil? || client_version.empty?
278
564
 
@@ -286,20 +572,20 @@ class Tidewave
286
572
  "name" => "tidewave",
287
573
  "version" => VERSION
288
574
  },
289
- "tools" => tool_definitions
575
+ "tools" => tool_definitions(context)
290
576
  })
291
577
  end
292
578
 
293
- def handle_tool_call(request_id, params)
579
+ def handle_tool_call(request_id, params, context)
294
580
  tool_name = params["name"]
295
581
  arguments = params["arguments"].is_a?(Hash) ? params["arguments"] : {}
296
582
 
297
583
  return jsonrpc_error_response_body(request_id, -32602, "Tool name is required") if tool_name.nil? || tool_name.empty?
298
584
 
299
- tool = @tools[tool_name]
585
+ tool = context[:tools][tool_name]
300
586
  return jsonrpc_error_response_body(request_id, -32601, "Tool '#{tool_name}' not found") if tool.nil?
301
587
 
302
- result = tool.validate_and_call(arguments)
588
+ result = tool.validate_and_call(arguments, context)
303
589
  jsonrpc_success_response_body(request_id, tool_result(result))
304
590
  rescue StandardError => error
305
591
  @logger&.error("Tool execution error: #{error.message}")
@@ -329,8 +615,8 @@ class Tidewave
329
615
  }
330
616
  end
331
617
 
332
- def tool_definitions
333
- @tools.values.map(&:definition)
618
+ def tool_definitions(context)
619
+ context[:tools].values.map(&:definition)
334
620
  end
335
621
 
336
622
  def tool_error_result(message)
@@ -342,10 +628,9 @@ class Tidewave
342
628
 
343
629
  def tool_result(result)
344
630
  if result.is_a?(Hash)
345
- {
346
- "content" => [ text_content(JSON.generate(result)) ],
347
- "structuredContent" => result
348
- }
631
+ # The tool returned a complete MCP result (browser_eval passes the
632
+ # browser's reply, including isError, through verbatim)
633
+ result
349
634
  else
350
635
  {
351
636
  "content" => [ text_content(result.to_s) ]
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tidewave
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.2
4
+ version: 0.8.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yorick Jacquin
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2026-07-09 00:00:00.000000000 Z
12
+ date: 2026-07-30 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: rack
@@ -36,14 +36,17 @@ files:
36
36
  - README.md
37
37
  - config/database.yml
38
38
  - lib/tidewave.rb
39
+ - lib/tidewave/browser_control.rb
39
40
  - lib/tidewave/configuration.rb
40
41
  - lib/tidewave/database_adapter.rb
41
42
  - lib/tidewave/database_adapters/active_record.rb
42
43
  - lib/tidewave/database_adapters/sequel.rb
43
44
  - lib/tidewave/exceptions_middleware.rb
45
+ - lib/tidewave/magic_bytes.rb
44
46
  - lib/tidewave/quiet_requests_middleware.rb
45
47
  - lib/tidewave/railtie.rb
46
48
  - lib/tidewave/tool.rb
49
+ - lib/tidewave/tools/browser_eval.rb
47
50
  - lib/tidewave/tools/execute_sql_query.rb
48
51
  - lib/tidewave/tools/get_docs.rb
49
52
  - lib/tidewave/tools/get_logs.rb
@@ -66,7 +69,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
66
69
  requirements:
67
70
  - - ">="
68
71
  - !ruby/object:Gem::Version
69
- version: '0'
72
+ version: '3.2'
70
73
  required_rubygems_version: !ruby/object:Gem::Requirement
71
74
  requirements:
72
75
  - - ">="