tidewave 0.8.0 → 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: d8aec0fffb76b0c91651452a2d8e2b1adc3fe37681a0c778c01a4762602985ed
4
- data.tar.gz: d0f6abae17bcc8281baed3f3db784b0243186c5008a8866a612e42bda4333e14
3
+ metadata.gz: 1802cbd495807edd296e6ee4b1a413509159e4b15f5aacbb84a0730bb73e51dc
4
+ data.tar.gz: 263ca6f1ab17b6b2447a1e93cb414483b46ef5842d6408a3b4fb70716ad863c5
5
5
  SHA512:
6
- metadata.gz: 8b683cb4a188e66e456078a39467a10ff6bc9aa1ac3b3f2e92338eadf5cbe1b0a693e6d80dd32d6e8dfdb167dd0b83af1d1183c442fc6d8348beb036180e05ba
7
- data.tar.gz: b3366af6bdd77c9312e8e6d70e56d898b02f75555f9b1932fea379bdb36621a8381f31df8696f86f7a6a5604885ae0ded2f22ee51a9bf323e80c7c7e52422c99
6
+ metadata.gz: 63a8ac146282fc3e3619c8981867c1d45aa939a6d4885f476b5ca995ecc9a7e7a18bb4c1e5bdb055bdace0114318127fed5be7c65191677ad05c9747a24b032b
7
+ data.tar.gz: db620af0482b721917ee736308946624e9d842a250473d72632c11628023cb4c762fa4b21663ab37521007c6bcc7ebf922116545630db821ecc3eb9c0ae7f46f
data/README.md CHANGED
@@ -42,7 +42,6 @@ We also have specific instructions for:
42
42
  - [Neovim](https://tidewave.hexdocs.pm/mcp_neovim.html)
43
43
  - [OpenCode](https://tidewave.hexdocs.pm/mcp_opencode.html)
44
44
  - [VS Code](https://tidewave.hexdocs.pm/mcp_vscode.html)
45
- - [Zed](https://tidewave.hexdocs.pm/mcp_zed.html)
46
45
  - [Others](https://tidewave.hexdocs.pm/mcp.html)
47
46
 
48
47
  ## Usage
@@ -155,7 +154,7 @@ You may configure `tidewave` using the following syntax:
155
154
 
156
155
  The following config is available:
157
156
 
158
- * `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)
159
158
 
160
159
  * `logger_middleware` - The logger middleware Tidewave should wrap to silence its own logs
161
160
 
@@ -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,11 +2,15 @@
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, :toolbar
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
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "logger"
4
4
  require "uri"
5
+ require "tidewave/browser_control"
5
6
  require "tidewave/configuration"
6
7
  require "tidewave/exceptions_middleware"
7
8
  require "tidewave/quiet_requests_middleware"
@@ -10,6 +11,21 @@ class Tidewave
10
11
  class Railtie < Rails::Railtie
11
12
  config.tidewave = Tidewave::Configuration.new()
12
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
+
13
29
  initializer "tidewave.setup" do |app|
14
30
  unless app.config.enable_reloading
15
31
  raise "For security reasons, Tidewave is only supported in environments where config.enable_reloading is true (typically development)"
@@ -21,6 +37,7 @@ class Tidewave
21
37
  ActionDispatch::Callbacks,
22
38
  Tidewave,
23
39
  allow_remote_access: tidewave_config.allow_remote_access,
40
+ browser_control: Tidewave::BrowserControl.new(cable: tidewave_config.cable || Railtie.cable_config(app)),
24
41
  client_url: tidewave_config.client_url,
25
42
  framework_type: "rails",
26
43
  project_name: app.class.module_parent.name,
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Tidewave
4
- VERSION = "0.8.0"
4
+ VERSION = "0.8.1"
5
5
  end
data/lib/tidewave.rb CHANGED
@@ -6,7 +6,6 @@ require "ipaddr"
6
6
  require "json"
7
7
  require "pathname"
8
8
  require "rack/request"
9
- require "uri"
10
9
  require "tidewave/version"
11
10
  require "tidewave/tool"
12
11
  require "tidewave/database_adapter"
@@ -73,8 +72,9 @@ class Tidewave
73
72
  TIDEWAVE_ROUTE = "tidewave".freeze
74
73
  MCP_ROUTE = "mcp".freeze
75
74
  CONFIG_ROUTE = "config".freeze
76
- APP_ROUTE = "app".freeze
75
+ CONNECT_ROUTE = "connect".freeze
77
76
  UPLOAD_ROUTE = "upload".freeze
77
+ WS_ROUTE = "ws".freeze
78
78
  PROTOCOL_VERSION = "2025-03-26".freeze
79
79
  MAX_UPLOAD_SIZE = 10_000_000
80
80
  ALLOWED_UPLOAD_CONTENT_TYPES = [ "image/png", "image/jpeg", "video/webm" ].freeze
@@ -87,6 +87,7 @@ class Tidewave
87
87
  If you really want to allow remote connections, configure Tidewave with the `allow_remote_access: true` option
88
88
  TEXT
89
89
 
90
+ INVALID_FETCH_SITE = "For security reasons, Tidewave only accepts requests from the same origin your web app is running on.".freeze
90
91
  INVALID_ORIGIN = "For security reasons, Tidewave does not accept requests with an origin header for this endpoint.".freeze
91
92
  INVALID_UPLOAD = "Bad Request: missing or invalid file parameter".freeze
92
93
  ENCODED_HTML_WARNING = <<~TEXT.freeze
@@ -97,6 +98,7 @@ class Tidewave
97
98
 
98
99
  DEFAULT_OPTIONS = {
99
100
  allow_remote_access: false,
101
+ browser_control: nil,
100
102
  client_url: "https://tidewave.ai",
101
103
  framework_type: "rack",
102
104
  team: {},
@@ -110,6 +112,7 @@ class Tidewave
110
112
 
111
113
  @logger = @options[:logger]
112
114
  @root = @options[:root] ? Pathname.new(@options[:root].to_s) : Pathname.pwd
115
+ @browser_control = @options[:browser_control]
113
116
  @tools = build_tool_registry
114
117
  end
115
118
 
@@ -120,12 +123,19 @@ class Tidewave
120
123
  if path[0] == TIDEWAVE_ROUTE
121
124
  return forbidden(INVALID_IP) unless valid_client_ip?(request)
122
125
 
123
- return forbidden(INVALID_ORIGIN) if request.get_header("HTTP_ORIGIN") && !origin_allowed_path?(path)
126
+ origin_error = check_origin(request, path)
127
+ return origin_error if origin_error
124
128
 
125
129
  case [ request.request_method, path ]
126
130
  when [ "GET", [ TIDEWAVE_ROUTE ] ]
127
131
  home_endpoint(request)
128
- when [ "GET", [ TIDEWAVE_ROUTE, APP_ROUTE ] ]
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 ] ]
129
139
  app_endpoint(request)
130
140
  when [ "GET", [ TIDEWAVE_ROUTE, CONFIG_ROUTE ] ]
131
141
  config_endpoint(request)
@@ -168,7 +178,7 @@ class Tidewave
168
178
  [ 200, response_headers("text/html", body), [ body ] ]
169
179
  end
170
180
 
171
- def app_endpoint(_request)
181
+ def app_endpoint(request)
172
182
  client_url = @options[:client_url].to_s.sub(%r{/\z}, "")
173
183
  body = <<~HTML
174
184
  <!DOCTYPE html>
@@ -176,6 +186,7 @@ class Tidewave
176
186
  <head>
177
187
  <meta charset="UTF-8" />
178
188
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
189
+ #{config_meta_tag(request)}
179
190
  <script type="module" src="#{client_url}/tc/control.js"></script>
180
191
  </head>
181
192
  <body></body>
@@ -191,13 +202,53 @@ class Tidewave
191
202
  json_response(config_data(request), headers: { "access-control-allow-origin" => "*" })
192
203
  end
193
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
+
194
244
  def mcp_endpoint(request)
195
245
  message = JSON.parse(request.body.read)
246
+ context = mcp_context(request)
196
247
 
197
248
  if message.is_a?(Array)
198
- handle_mcp_batch(message)
249
+ handle_mcp_batch(message, context)
199
250
  else
200
- handle_mcp_single(message)
251
+ handle_mcp_single(message, context)
201
252
  end
202
253
  rescue JSON::ParserError
203
254
  jsonrpc_error_response(nil, -32700, "Parse error", status: 400)
@@ -206,26 +257,36 @@ class Tidewave
206
257
  jsonrpc_error_response(nil, -32603, "Internal error")
207
258
  end
208
259
 
209
- 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)
210
271
  validation_error = validate_jsonrpc_message(message)
211
272
  return jsonrpc_error_response(nil, -32600, validation_error, status: 400) if validation_error
212
273
 
213
- response = handle_mcp_message(message)
274
+ response = handle_mcp_message(message, context)
214
275
  response.nil? ? accepted_response : json_response(response)
215
276
  end
216
277
 
217
- def handle_mcp_batch(messages)
278
+ def handle_mcp_batch(messages, context)
218
279
  return jsonrpc_error_response(nil, -32600, "Invalid Request", status: 400) if messages.empty?
219
280
 
220
- responses = messages.map { |message| handle_mcp_batch_message(message) }.compact
281
+ responses = messages.map { |message| handle_mcp_batch_message(message, context) }.compact
221
282
  responses.empty? ? accepted_response : json_response(responses)
222
283
  end
223
284
 
224
- def handle_mcp_batch_message(message)
285
+ def handle_mcp_batch_message(message, context)
225
286
  validation_error = validate_jsonrpc_message(message)
226
287
  return jsonrpc_error_response_body(nil, -32600, validation_error) if validation_error
227
288
 
228
- handle_mcp_message(message)
289
+ handle_mcp_message(message, context)
229
290
  end
230
291
 
231
292
  def config_data(request)
@@ -288,6 +349,14 @@ class Tidewave
288
349
 
289
350
  def toolbar_html(request)
290
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)
291
360
  payload = {
292
361
  "tidewave" => config_data(request),
293
362
  "root" => @root.to_s,
@@ -295,10 +364,7 @@ class Tidewave
295
364
  "framework" => {}
296
365
  }
297
366
 
298
- <<~HTML
299
- <meta name="tidewave:config" content="#{CGI.escapeHTML(JSON.generate(payload))}" />
300
- <script async type="module" src="#{client_url}/tc/toolbar.js"></script>
301
- HTML
367
+ %(<meta name="tidewave:config" content="#{CGI.escapeHTML(JSON.generate(payload))}" />)
302
368
  end
303
369
 
304
370
  def upload_endpoint(request)
@@ -359,15 +425,6 @@ class Tidewave
359
425
  }
360
426
  end
361
427
 
362
- def origin_allowed_path?(path)
363
- [
364
- [ TIDEWAVE_ROUTE ],
365
- [ TIDEWAVE_ROUTE, APP_ROUTE ],
366
- [ TIDEWAVE_ROUTE, CONFIG_ROUTE ],
367
- [ TIDEWAVE_ROUTE, UPLOAD_ROUTE ]
368
- ].include?(path)
369
- end
370
-
371
428
  def local_port(request)
372
429
  sock = request.env["puma.socket"]
373
430
  return unless sock
@@ -466,7 +523,7 @@ class Tidewave
466
523
  # Returns the JSON-RPC response for a request, or nil for messages that
467
524
  # must not be replied to (notifications and client-sent responses), which
468
525
  # the transport acknowledges with 202 Accepted.
469
- def handle_mcp_message(message)
526
+ def handle_mcp_message(message, context)
470
527
  return nil unless message.key?("method") && message.key?("id")
471
528
 
472
529
  method = message["method"]
@@ -477,11 +534,11 @@ class Tidewave
477
534
  when "ping"
478
535
  jsonrpc_success_response_body(request_id, {})
479
536
  when "initialize"
480
- handle_initialize(request_id, params)
537
+ handle_initialize(request_id, params, context)
481
538
  when "tools/list"
482
- jsonrpc_success_response_body(request_id, { "tools" => tool_definitions })
539
+ jsonrpc_success_response_body(request_id, { "tools" => tool_definitions(context) })
483
540
  when "tools/call"
484
- handle_tool_call(request_id, params)
541
+ handle_tool_call(request_id, params, context)
485
542
  when "prompts/list"
486
543
  jsonrpc_success_response_body(request_id, { "prompts" => [] })
487
544
  when "resources/list"
@@ -501,7 +558,7 @@ class Tidewave
501
558
  end
502
559
  end
503
560
 
504
- def handle_initialize(request_id, params)
561
+ def handle_initialize(request_id, params, context)
505
562
  client_version = params["protocolVersion"]
506
563
  return jsonrpc_error_response_body(request_id, -32602, "Protocol version is required") if client_version.nil? || client_version.empty?
507
564
 
@@ -515,20 +572,20 @@ class Tidewave
515
572
  "name" => "tidewave",
516
573
  "version" => VERSION
517
574
  },
518
- "tools" => tool_definitions
575
+ "tools" => tool_definitions(context)
519
576
  })
520
577
  end
521
578
 
522
- def handle_tool_call(request_id, params)
579
+ def handle_tool_call(request_id, params, context)
523
580
  tool_name = params["name"]
524
581
  arguments = params["arguments"].is_a?(Hash) ? params["arguments"] : {}
525
582
 
526
583
  return jsonrpc_error_response_body(request_id, -32602, "Tool name is required") if tool_name.nil? || tool_name.empty?
527
584
 
528
- tool = @tools[tool_name]
585
+ tool = context[:tools][tool_name]
529
586
  return jsonrpc_error_response_body(request_id, -32601, "Tool '#{tool_name}' not found") if tool.nil?
530
587
 
531
- result = tool.validate_and_call(arguments)
588
+ result = tool.validate_and_call(arguments, context)
532
589
  jsonrpc_success_response_body(request_id, tool_result(result))
533
590
  rescue StandardError => error
534
591
  @logger&.error("Tool execution error: #{error.message}")
@@ -558,8 +615,8 @@ class Tidewave
558
615
  }
559
616
  end
560
617
 
561
- def tool_definitions
562
- @tools.values.map(&:definition)
618
+ def tool_definitions(context)
619
+ context[:tools].values.map(&:definition)
563
620
  end
564
621
 
565
622
  def tool_error_result(message)
@@ -571,10 +628,9 @@ class Tidewave
571
628
 
572
629
  def tool_result(result)
573
630
  if result.is_a?(Hash)
574
- {
575
- "content" => [ text_content(JSON.generate(result)) ],
576
- "structuredContent" => result
577
- }
631
+ # The tool returned a complete MCP result (browser_eval passes the
632
+ # browser's reply, including isError, through verbatim)
633
+ result
578
634
  else
579
635
  {
580
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.8.0
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-22 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,6 +36,7 @@ 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
@@ -45,6 +46,7 @@ files:
45
46
  - lib/tidewave/quiet_requests_middleware.rb
46
47
  - lib/tidewave/railtie.rb
47
48
  - lib/tidewave/tool.rb
49
+ - lib/tidewave/tools/browser_eval.rb
48
50
  - lib/tidewave/tools/execute_sql_query.rb
49
51
  - lib/tidewave/tools/get_docs.rb
50
52
  - lib/tidewave/tools/get_logs.rb
@@ -67,7 +69,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
67
69
  requirements:
68
70
  - - ">="
69
71
  - !ruby/object:Gem::Version
70
- version: '0'
72
+ version: '3.2'
71
73
  required_rubygems_version: !ruby/object:Gem::Requirement
72
74
  requirements:
73
75
  - - ">="