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 +4 -4
- data/README.md +9 -6
- data/lib/tidewave/browser_control.rb +283 -0
- data/lib/tidewave/configuration.rb +6 -1
- data/lib/tidewave/magic_bytes.rb +20 -0
- data/lib/tidewave/railtie.rb +31 -2
- data/lib/tidewave/tool.rb +9 -2
- data/lib/tidewave/tools/browser_eval.rb +102 -0
- data/lib/tidewave/tools/execute_sql_query.rb +12 -1
- data/lib/tidewave/version.rb +1 -1
- data/lib/tidewave.rb +318 -33
- metadata +6 -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
|
@@ -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/
|
|
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
|
data/lib/tidewave/railtie.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/tidewave/version.rb
CHANGED
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
347
|
-
|
|
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.
|
|
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,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: '
|
|
72
|
+
version: '3.2'
|
|
70
73
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
71
74
|
requirements:
|
|
72
75
|
- - ">="
|