Skip to content

Commit d236dac

Browse files
Add browser_eval support (#107)
1 parent e2950f3 commit d236dac

14 files changed

Lines changed: 1127 additions & 73 deletions

Gemfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ group :development, :test do
1717
gem "pry"
1818
gem "appraisal"
1919
gem "railties", ">= 7.1.0"
20+
gem "actioncable", ">= 7.1.0"
2021
gem "activerecord", ">= 7.1.0"
2122
gem "sequel"
2223
end

Gemfile.lock

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ PATH
77
GEM
88
remote: https://rubygems.org/
99
specs:
10+
actioncable (8.0.2)
11+
actionpack (= 8.0.2)
12+
activesupport (= 8.0.2)
13+
nio4r (~> 2.0)
14+
websocket-driver (>= 0.6.1)
15+
zeitwerk (~> 2.6)
1016
actionpack (8.0.2)
1117
actionview (= 8.0.2)
1218
activesupport (= 8.0.2)
@@ -75,7 +81,10 @@ GEM
7581
nokogiri (>= 1.12.0)
7682
method_source (1.1.0)
7783
mini_portile2 (2.8.9)
78-
minitest (5.25.5)
84+
minitest (6.0.6)
85+
drb (~> 2.0)
86+
prism (~> 1.5)
87+
nio4r (2.7.5)
7988
nokogiri (1.19.3)
8089
mini_portile2 (~> 2.8.2)
8190
racc (~> 1.4)
@@ -96,7 +105,7 @@ GEM
96105
pp (0.6.2)
97106
prettyprint
98107
prettyprint (0.2.0)
99-
prism (1.4.0)
108+
prism (1.9.0)
100109
pry (0.15.2)
101110
coderay (~> 1.1)
102111
method_source (~> 1.0)
@@ -181,9 +190,13 @@ GEM
181190
concurrent-ruby (~> 1.0)
182191
unicode-display_width (3.1.4)
183192
unicode-emoji (~> 4.0, >= 4.0.4)
184-
unicode-emoji (4.0.4)
193+
unicode-emoji (4.2.0)
185194
uri (1.0.3)
186195
useragent (0.16.11)
196+
websocket-driver (0.8.2)
197+
base64
198+
websocket-extensions (>= 0.1.0)
199+
websocket-extensions (0.1.5)
187200
zeitwerk (2.7.3)
188201

189202
PLATFORMS
@@ -195,6 +208,7 @@ PLATFORMS
195208
x86_64-linux
196209

197210
DEPENDENCIES
211+
actioncable (>= 7.1.0)
198212
activerecord (>= 7.1.0)
199213
appraisal
200214
pry

lib/tidewave.rb

Lines changed: 97 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
require "json"
77
require "pathname"
88
require "rack/request"
9-
require "uri"
109
require "tidewave/version"
1110
require "tidewave/tool"
1211
require "tidewave/database_adapter"
@@ -73,8 +72,9 @@ def close
7372
TIDEWAVE_ROUTE = "tidewave".freeze
7473
MCP_ROUTE = "mcp".freeze
7574
CONFIG_ROUTE = "config".freeze
76-
APP_ROUTE = "app".freeze
75+
CONNECT_ROUTE = "connect".freeze
7776
UPLOAD_ROUTE = "upload".freeze
77+
WS_ROUTE = "ws".freeze
7878
PROTOCOL_VERSION = "2025-03-26".freeze
7979
MAX_UPLOAD_SIZE = 10_000_000
8080
ALLOWED_UPLOAD_CONTENT_TYPES = [ "image/png", "image/jpeg", "video/webm" ].freeze
@@ -87,6 +87,7 @@ def close
8787
If you really want to allow remote connections, configure Tidewave with the `allow_remote_access: true` option
8888
TEXT
8989

90+
INVALID_FETCH_SITE = "For security reasons, Tidewave only accepts requests from the same origin your web app is running on.".freeze
9091
INVALID_ORIGIN = "For security reasons, Tidewave does not accept requests with an origin header for this endpoint.".freeze
9192
INVALID_UPLOAD = "Bad Request: missing or invalid file parameter".freeze
9293
ENCODED_HTML_WARNING = <<~TEXT.freeze
@@ -97,6 +98,7 @@ def close
9798

9899
DEFAULT_OPTIONS = {
99100
allow_remote_access: false,
101+
browser_control: nil,
100102
client_url: "https://tidewave.ai",
101103
framework_type: "rack",
102104
team: {},
@@ -110,6 +112,7 @@ def initialize(app, options = {})
110112

111113
@logger = @options[:logger]
112114
@root = @options[:root] ? Pathname.new(@options[:root].to_s) : Pathname.pwd
115+
@browser_control = @options[:browser_control]
113116
@tools = build_tool_registry
114117
end
115118

@@ -120,12 +123,19 @@ def call(env)
120123
if path[0] == TIDEWAVE_ROUTE
121124
return forbidden(INVALID_IP) unless valid_client_ip?(request)
122125

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
124128

125129
case [ request.request_method, path ]
126130
when [ "GET", [ TIDEWAVE_ROUTE ] ]
127131
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 ] ]
129139
app_endpoint(request)
130140
when [ "GET", [ TIDEWAVE_ROUTE, CONFIG_ROUTE ] ]
131141
config_endpoint(request)
@@ -168,14 +178,15 @@ def home_endpoint(_request)
168178
[ 200, response_headers("text/html", body), [ body ] ]
169179
end
170180

171-
def app_endpoint(_request)
181+
def app_endpoint(request)
172182
client_url = @options[:client_url].to_s.sub(%r{/\z}, "")
173183
body = <<~HTML
174184
<!DOCTYPE html>
175185
<html>
176186
<head>
177187
<meta charset="UTF-8" />
178188
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
189+
#{config_meta_tag(request)}
179190
<script type="module" src="#{client_url}/tc/control.js"></script>
180191
</head>
181192
<body></body>
@@ -191,13 +202,53 @@ def config_endpoint(request)
191202
json_response(config_data(request), headers: { "access-control-allow-origin" => "*" })
192203
end
193204

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+
194244
def mcp_endpoint(request)
195245
message = JSON.parse(request.body.read)
246+
context = mcp_context(request)
196247

197248
if message.is_a?(Array)
198-
handle_mcp_batch(message)
249+
handle_mcp_batch(message, context)
199250
else
200-
handle_mcp_single(message)
251+
handle_mcp_single(message, context)
201252
end
202253
rescue JSON::ParserError
203254
jsonrpc_error_response(nil, -32700, "Parse error", status: 400)
@@ -206,26 +257,36 @@ def mcp_endpoint(request)
206257
jsonrpc_error_response(nil, -32603, "Internal error")
207258
end
208259

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)
210271
validation_error = validate_jsonrpc_message(message)
211272
return jsonrpc_error_response(nil, -32600, validation_error, status: 400) if validation_error
212273

213-
response = handle_mcp_message(message)
274+
response = handle_mcp_message(message, context)
214275
response.nil? ? accepted_response : json_response(response)
215276
end
216277

217-
def handle_mcp_batch(messages)
278+
def handle_mcp_batch(messages, context)
218279
return jsonrpc_error_response(nil, -32600, "Invalid Request", status: 400) if messages.empty?
219280

220-
responses = messages.map { |message| handle_mcp_batch_message(message) }.compact
281+
responses = messages.map { |message| handle_mcp_batch_message(message, context) }.compact
221282
responses.empty? ? accepted_response : json_response(responses)
222283
end
223284

224-
def handle_mcp_batch_message(message)
285+
def handle_mcp_batch_message(message, context)
225286
validation_error = validate_jsonrpc_message(message)
226287
return jsonrpc_error_response_body(nil, -32600, validation_error) if validation_error
227288

228-
handle_mcp_message(message)
289+
handle_mcp_message(message, context)
229290
end
230291

231292
def config_data(request)
@@ -288,17 +349,22 @@ def warn_encoded_html
288349

289350
def toolbar_html(request)
290351
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)
291360
payload = {
292361
"tidewave" => config_data(request),
293362
"root" => @root.to_s,
294363
"wsl_distro" => ENV["WSL_DISTRO_NAME"],
295364
"framework" => {}
296365
}
297366

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))}" />)
302368
end
303369

304370
def upload_endpoint(request)
@@ -359,15 +425,6 @@ def response_headers(content_type, body)
359425
}
360426
end
361427

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-
371428
def local_port(request)
372429
sock = request.env["puma.socket"]
373430
return unless sock
@@ -466,7 +523,7 @@ def validate_jsonrpc_message(message)
466523
# Returns the JSON-RPC response for a request, or nil for messages that
467524
# must not be replied to (notifications and client-sent responses), which
468525
# the transport acknowledges with 202 Accepted.
469-
def handle_mcp_message(message)
526+
def handle_mcp_message(message, context)
470527
return nil unless message.key?("method") && message.key?("id")
471528

472529
method = message["method"]
@@ -477,11 +534,11 @@ def handle_mcp_message(message)
477534
when "ping"
478535
jsonrpc_success_response_body(request_id, {})
479536
when "initialize"
480-
handle_initialize(request_id, params)
537+
handle_initialize(request_id, params, context)
481538
when "tools/list"
482-
jsonrpc_success_response_body(request_id, { "tools" => tool_definitions })
539+
jsonrpc_success_response_body(request_id, { "tools" => tool_definitions(context) })
483540
when "tools/call"
484-
handle_tool_call(request_id, params)
541+
handle_tool_call(request_id, params, context)
485542
when "prompts/list"
486543
jsonrpc_success_response_body(request_id, { "prompts" => [] })
487544
when "resources/list"
@@ -501,7 +558,7 @@ def handle_mcp_message(message)
501558
end
502559
end
503560

504-
def handle_initialize(request_id, params)
561+
def handle_initialize(request_id, params, context)
505562
client_version = params["protocolVersion"]
506563
return jsonrpc_error_response_body(request_id, -32602, "Protocol version is required") if client_version.nil? || client_version.empty?
507564

@@ -515,20 +572,20 @@ def handle_initialize(request_id, params)
515572
"name" => "tidewave",
516573
"version" => VERSION
517574
},
518-
"tools" => tool_definitions
575+
"tools" => tool_definitions(context)
519576
})
520577
end
521578

522-
def handle_tool_call(request_id, params)
579+
def handle_tool_call(request_id, params, context)
523580
tool_name = params["name"]
524581
arguments = params["arguments"].is_a?(Hash) ? params["arguments"] : {}
525582

526583
return jsonrpc_error_response_body(request_id, -32602, "Tool name is required") if tool_name.nil? || tool_name.empty?
527584

528-
tool = @tools[tool_name]
585+
tool = context[:tools][tool_name]
529586
return jsonrpc_error_response_body(request_id, -32601, "Tool '#{tool_name}' not found") if tool.nil?
530587

531-
result = tool.validate_and_call(arguments)
588+
result = tool.validate_and_call(arguments, context)
532589
jsonrpc_success_response_body(request_id, tool_result(result))
533590
rescue StandardError => error
534591
@logger&.error("Tool execution error: #{error.message}")
@@ -558,8 +615,8 @@ def jsonrpc_error_response_body(request_id, code, message)
558615
}
559616
end
560617

561-
def tool_definitions
562-
@tools.values.map(&:definition)
618+
def tool_definitions(context)
619+
context[:tools].values.map(&:definition)
563620
end
564621

565622
def tool_error_result(message)
@@ -571,10 +628,9 @@ def tool_error_result(message)
571628

572629
def tool_result(result)
573630
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
578634
else
579635
{
580636
"content" => [ text_content(result.to_s) ]

0 commit comments

Comments
 (0)