66require "json"
77require "pathname"
88require "rack/request"
9- require "uri"
109require "tidewave/version"
1110require "tidewave/tool"
1211require "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