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 +4 -4
- data/README.md +1 -2
- data/lib/tidewave/browser_control.rb +283 -0
- data/lib/tidewave/configuration.rb +5 -1
- data/lib/tidewave/railtie.rb +17 -0
- data/lib/tidewave/tool.rb +9 -2
- data/lib/tidewave/tools/browser_eval.rb +102 -0
- data/lib/tidewave/version.rb +1 -1
- data/lib/tidewave.rb +97 -41
- metadata +5 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1802cbd495807edd296e6ee4b1a413509159e4b15f5aacbb84a0730bb73e51dc
|
|
4
|
+
data.tar.gz: 263ca6f1ab17b6b2447a1e93cb414483b46ef5842d6408a3b4fb70716ad863c5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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/
|
|
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
|
data/lib/tidewave/railtie.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
data/lib/tidewave/version.rb
CHANGED
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
|
-
|
|
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
|
-
|
|
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,
|
|
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(
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
576
|
-
|
|
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.
|
|
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-
|
|
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: '
|
|
72
|
+
version: '3.2'
|
|
71
73
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
72
74
|
requirements:
|
|
73
75
|
- - ">="
|