tidewave 0.5.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c64d42ec2ee75f35b02997081d24e69fe547d8b9a20a230daeb1d1995a30cd0a
4
- data.tar.gz: 74e1cc3e89cdea2b1d4701990ca3fbe3b4b3a5c828735bef5251c91a1d8c074a
3
+ metadata.gz: d8aec0fffb76b0c91651452a2d8e2b1adc3fe37681a0c778c01a4762602985ed
4
+ data.tar.gz: d0f6abae17bcc8281baed3f3db784b0243186c5008a8866a612e42bda4333e14
5
5
  SHA512:
6
- metadata.gz: a2cf21787f51875cbec78b12b5800c7f03b8d516360fd960110f8717dc762f47e58f4f7a4b243c3f0120e254075f17495e86d6c3538d22dd8abe6ac046d3fc6d
7
- data.tar.gz: 789f139be709d1bc9a0d55b8cb661b9212b3ce2b4fa546d2c644b3a676a6a2843687407e2efdc87c8a0785290c2aab1806693b07698cd71f9fb9e7f130714786
6
+ metadata.gz: 8b683cb4a188e66e456078a39467a10ff6bc9aa1ac3b3f2e92338eadf5cbe1b0a693e6d80dd32d6e8dfdb167dd0b83af1d1183c442fc6d8348beb036180e05ba
7
+ data.tar.gz: b3366af6bdd77c9312e8e6d70e56d898b02f75555f9b1932fea379bdb36621a8381f31df8696f86f7a6a5604885ae0ded2f22ee51a9bf323e80c7c7e52422c99
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
 
@@ -117,6 +115,10 @@ Also, because it resolves the location from your running app instead of parsing
117
115
 
118
116
  ## Troubleshooting
119
117
 
118
+ ### The Tidewave toolbar is missing
119
+
120
+ 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.
121
+
120
122
  ### Using multiple hosts/subdomains
121
123
 
122
124
  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 +137,7 @@ The above will allow your application to run embedded within Tidewave across mul
135
137
 
136
138
  ### Content security policy
137
139
 
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.
140
+ 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
141
 
140
142
  ### Production Environment
141
143
 
@@ -161,6 +163,8 @@ The following config is available:
161
163
 
162
164
  * `team` - set your Tidewave Team configuration, such as `config.tidewave.team = { id: "my-company" }`
163
165
 
166
+ * `toolbar` - controls whether the Tidewave toolbar is injected into HTML pages. Defaults to `true`
167
+
164
168
  ## Acknowledgements
165
169
 
166
170
  A thank you to Yorick Jacquin for the initial version of this project.
@@ -2,7 +2,7 @@
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, :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
@@ -13,6 +13,7 @@ class Tidewave
13
13
  @client_url = "https://tidewave.ai"
14
14
  @team = {}
15
15
  @logger_middleware = nil
16
+ @toolbar = true
16
17
  end
17
18
  end
18
19
  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
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "logger"
4
+ require "uri"
4
5
  require "tidewave/configuration"
5
6
  require "tidewave/exceptions_middleware"
6
7
  require "tidewave/quiet_requests_middleware"
@@ -24,6 +25,7 @@ class Tidewave
24
25
  framework_type: "rails",
25
26
  project_name: app.class.module_parent.name,
26
27
  team: tidewave_config.team,
28
+ toolbar: tidewave_config.toolbar,
27
29
  logger: tidewave_config.logger || Rails.logger,
28
30
  root: Rails.root,
29
31
  log_file: Rails.root.join("log", "#{Rails.env}.log"),
@@ -35,11 +37,21 @@ class Tidewave
35
37
  # If the user configured CSP, we need to alter it in dev
36
38
  # to allow TC to run browser_eval.
37
39
  app.config.content_security_policy.try do |content_security_policy|
38
- content_security_policy.directives["script-src"].try do |script_src|
40
+ directives = content_security_policy.directives
41
+ script_src = directives["script-src"] || directives["default-src"]&.dup
42
+ client_origin = URI.parse(tidewave_config.client_url.to_s).origin
43
+
44
+ script_src.try do
39
45
  script_src << "'unsafe-eval'" unless script_src.include?("'unsafe-eval'")
46
+ script_src << client_origin unless script_src.include?(client_origin)
47
+ directives["script-src"] = script_src
48
+ end
49
+
50
+ directives["script-src-elem"].try do |script_src_elem|
51
+ script_src_elem << client_origin unless script_src_elem.include?(client_origin)
40
52
  end
41
53
 
42
- content_security_policy.directives.delete("frame-ancestors")
54
+ directives.delete("frame-ancestors")
43
55
  end
44
56
  end
45
57
  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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Tidewave
4
- VERSION = "0.5.2"
4
+ VERSION = "0.8.0"
5
5
  end
data/lib/tidewave.rb CHANGED
@@ -1,11 +1,16 @@
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"
9
+ require "uri"
6
10
  require "tidewave/version"
7
11
  require "tidewave/tool"
8
12
  require "tidewave/database_adapter"
13
+ require "tidewave/magic_bytes"
9
14
  require "tidewave/railtie" if defined?(Rails::Railtie)
10
15
 
11
16
  class Tidewave
@@ -25,10 +30,56 @@ Dir[gem_tools_path].sort.each do |file|
25
30
  end
26
31
 
27
32
  class Tidewave
33
+ class ToolbarBody
34
+ def initialize(body, toolbar)
35
+ @body = body
36
+ @toolbar = toolbar
37
+ @closed = false
38
+ end
39
+
40
+ def each
41
+ return enum_for(:each) unless block_given?
42
+
43
+ pending = +""
44
+ injected = false
45
+
46
+ @body.each do |part|
47
+ if injected
48
+ yield part
49
+ else
50
+ pending << part
51
+
52
+ if closing_head = pending.downcase.index("</head>")
53
+ toolbar = @toolbar.dup.force_encoding(pending.encoding)
54
+ output = pending.insert(closing_head, toolbar)
55
+ pending = nil
56
+ injected = true
57
+ yield output
58
+ end
59
+ end
60
+ end
61
+
62
+ yield pending unless injected || pending.empty?
63
+ end
64
+
65
+ def close
66
+ return if @closed
67
+
68
+ @closed = true
69
+ @body.close if @body.respond_to?(:close)
70
+ end
71
+ end
72
+
28
73
  TIDEWAVE_ROUTE = "tidewave".freeze
29
74
  MCP_ROUTE = "mcp".freeze
30
75
  CONFIG_ROUTE = "config".freeze
76
+ APP_ROUTE = "app".freeze
77
+ UPLOAD_ROUTE = "upload".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.
@@ -37,12 +88,19 @@ class Tidewave
37
88
  TEXT
38
89
 
39
90
  INVALID_ORIGIN = "For security reasons, Tidewave does not accept requests with an origin header for this endpoint.".freeze
91
+ INVALID_UPLOAD = "Bad Request: missing or invalid file parameter".freeze
92
+ ENCODED_HTML_WARNING = <<~TEXT.freeze
93
+ Tidewave could not inject the toolbar because the HTML response is encoded.
94
+
95
+ If you use Rack::Deflater or another compression middleware, place it before Tidewave in the middleware stack.
96
+ TEXT
40
97
 
41
98
  DEFAULT_OPTIONS = {
42
99
  allow_remote_access: false,
43
100
  client_url: "https://tidewave.ai",
44
101
  framework_type: "rack",
45
- team: {}
102
+ team: {},
103
+ toolbar: true
46
104
  }.freeze
47
105
 
48
106
  def initialize(app, options = {})
@@ -51,6 +109,7 @@ class Tidewave
51
109
  raise ArgumentError, "project_name is required" if @options[:project_name].to_s.empty?
52
110
 
53
111
  @logger = @options[:logger]
112
+ @root = @options[:root] ? Pathname.new(@options[:root].to_s) : Pathname.pwd
54
113
  @tools = build_tool_registry
55
114
  end
56
115
 
@@ -60,24 +119,27 @@ class Tidewave
60
119
 
61
120
  if path[0] == TIDEWAVE_ROUTE
62
121
  return forbidden(INVALID_IP) unless valid_client_ip?(request)
63
- if request.get_header("HTTP_ORIGIN") && !origin_allowed_path?(path)
64
- return forbidden(INVALID_ORIGIN)
65
- end
122
+
123
+ return forbidden(INVALID_ORIGIN) if request.get_header("HTTP_ORIGIN") && !origin_allowed_path?(path)
66
124
 
67
125
  case [ request.request_method, path ]
68
126
  when [ "GET", [ TIDEWAVE_ROUTE ] ]
69
127
  home_endpoint(request)
128
+ when [ "GET", [ TIDEWAVE_ROUTE, APP_ROUTE ] ]
129
+ app_endpoint(request)
70
130
  when [ "GET", [ TIDEWAVE_ROUTE, CONFIG_ROUTE ] ]
71
131
  config_endpoint(request)
72
132
  when [ "POST", [ TIDEWAVE_ROUTE, MCP_ROUTE ] ]
73
133
  mcp_endpoint(request)
134
+ when [ "POST", [ TIDEWAVE_ROUTE, UPLOAD_ROUTE ] ]
135
+ upload_endpoint(request)
74
136
  else
75
137
  # The MCP Streamable HTTP transport requires the MCP endpoint to answer
76
138
  # non-POST methods with 405 (GET without SSE support, DELETE, etc.)
77
139
  path == [ TIDEWAVE_ROUTE, MCP_ROUTE ] ? method_not_allowed() : not_found()
78
140
  end
79
141
  else
80
- strip_x_frame_options(@app.call(env))
142
+ inject_toolbar(request, strip_x_frame_options(@app.call(env)))
81
143
  end
82
144
  end
83
145
 
@@ -106,6 +168,25 @@ class Tidewave
106
168
  [ 200, response_headers("text/html", body), [ body ] ]
107
169
  end
108
170
 
171
+ def app_endpoint(_request)
172
+ client_url = @options[:client_url].to_s.sub(%r{/\z}, "")
173
+ body = <<~HTML
174
+ <!DOCTYPE html>
175
+ <html>
176
+ <head>
177
+ <meta charset="UTF-8" />
178
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
179
+ <script type="module" src="#{client_url}/tc/control.js"></script>
180
+ </head>
181
+ <body></body>
182
+ </html>
183
+ HTML
184
+
185
+ headers = response_headers("text/html", body)
186
+ headers["content-security-policy"] = "base-uri 'self'; frame-ancestors 'self';"
187
+ [ 200, headers, [ body ] ]
188
+ end
189
+
109
190
  def config_endpoint(request)
110
191
  json_response(config_data(request), headers: { "access-control-allow-origin" => "*" })
111
192
  end
@@ -154,10 +235,92 @@ class Tidewave
154
235
  "orm_adapter" => @options[:orm_adapter],
155
236
  "team" => @options[:team] || {},
156
237
  "tidewave_version" => VERSION,
157
- "local_port" => local_port(request)
238
+ "local_port" => local_port(request),
239
+ "tmp_dir" => TMP_DIR
158
240
  }
159
241
  end
160
242
 
243
+ def inject_toolbar(request, response)
244
+ status, headers, body = response
245
+ return response if @options[:toolbar] == false || !html_response?(headers)
246
+
247
+ if encoded_response?(headers)
248
+ warn_encoded_html
249
+ return response
250
+ end
251
+
252
+ return response unless body.respond_to?(:each)
253
+
254
+ delete_response_header(headers, "content-length")
255
+ delete_response_header(headers, "etag")
256
+ [ status, headers, ToolbarBody.new(body, toolbar_html(request)) ]
257
+ end
258
+
259
+ def html_response?(headers)
260
+ content_types = Array(response_header(headers, "content-type"))
261
+
262
+ content_types.any? { |content_type| content_type.to_s.downcase.start_with?("text/html") }
263
+ end
264
+
265
+ def encoded_response?(headers)
266
+ Array(response_header(headers, "content-encoding")).any? do |content_encoding|
267
+ content_encoding.to_s.split(",").any? do |encoding|
268
+ !encoding.strip.empty? && encoding.strip.downcase != "identity"
269
+ end
270
+ end
271
+ end
272
+
273
+ def response_header(headers, name)
274
+ key = headers.keys.find { |header| header.downcase == name }
275
+ headers[key] if key
276
+ end
277
+
278
+ def delete_response_header(headers, name)
279
+ headers.delete_if { |header, _value| header.downcase == name }
280
+ end
281
+
282
+ def warn_encoded_html
283
+ return if @warned_encoded_html
284
+
285
+ @warned_encoded_html = true
286
+ @logger&.warn(ENCODED_HTML_WARNING)
287
+ end
288
+
289
+ def toolbar_html(request)
290
+ client_url = @options[:client_url].to_s.sub(%r{/\z}, "")
291
+ payload = {
292
+ "tidewave" => config_data(request),
293
+ "root" => @root.to_s,
294
+ "wsl_distro" => ENV["WSL_DISTRO_NAME"],
295
+ "framework" => {}
296
+ }
297
+
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
302
+ end
303
+
304
+ def upload_endpoint(request)
305
+ return text_response(400, INVALID_UPLOAD) if upload_too_large?(request)
306
+
307
+ params = request.POST
308
+ type = params["type"]
309
+ upload = normalize_upload(params["file"])
310
+
311
+ unless ALLOWED_UPLOAD_TYPES.include?(type) && allowed_upload?(upload)
312
+ return text_response(400, INVALID_UPLOAD)
313
+ end
314
+
315
+ FileUtils.mkdir_p(upload_dir(type))
316
+ destination = upload_path(type, upload[:filename])
317
+ FileUtils.cp(upload[:path], destination)
318
+
319
+ json_response({ "status" => "ok", "path" => relative_path_from_root(destination) })
320
+ rescue ArgumentError
321
+ text_response(400, INVALID_UPLOAD)
322
+ end
323
+
161
324
  def json_response(payload, status: 200, headers: {})
162
325
  body = JSON.generate(payload)
163
326
  [ status, response_headers("application/json", body).merge(headers), [ body ] ]
@@ -197,7 +360,12 @@ class Tidewave
197
360
  end
198
361
 
199
362
  def origin_allowed_path?(path)
200
- path == [ TIDEWAVE_ROUTE ] || path == [ TIDEWAVE_ROUTE, CONFIG_ROUTE ]
363
+ [
364
+ [ TIDEWAVE_ROUTE ],
365
+ [ TIDEWAVE_ROUTE, APP_ROUTE ],
366
+ [ TIDEWAVE_ROUTE, CONFIG_ROUTE ],
367
+ [ TIDEWAVE_ROUTE, UPLOAD_ROUTE ]
368
+ ].include?(path)
201
369
  end
202
370
 
203
371
  def local_port(request)
@@ -220,6 +388,67 @@ class Tidewave
220
388
  false
221
389
  end
222
390
 
391
+ def upload_too_large?(request)
392
+ request.content_length && request.content_length.to_i > MAX_UPLOAD_SIZE
393
+ end
394
+
395
+ def normalize_upload(upload)
396
+ case upload
397
+ when Hash
398
+ tempfile = upload[:tempfile] || upload["tempfile"]
399
+ {
400
+ filename: upload[:filename] || upload["filename"],
401
+ content_type: upload[:type] || upload["type"],
402
+ path: tempfile&.path
403
+ }
404
+ else
405
+ return {} unless upload.respond_to?(:original_filename) && upload.respond_to?(:content_type)
406
+
407
+ {
408
+ filename: upload.original_filename,
409
+ content_type: upload.content_type,
410
+ path: upload.tempfile&.path
411
+ }
412
+ end
413
+ end
414
+
415
+ def allowed_upload?(upload)
416
+ ALLOWED_UPLOAD_CONTENT_TYPES.include?(upload[:content_type].to_s.split(";").first) &&
417
+ upload[:path] &&
418
+ Tidewave::MagicBytes.type(File.binread(upload[:path], 128)) != :unknown
419
+ end
420
+
421
+ def upload_dir(type)
422
+ @root.join(TMP_DIR, "tidewave", folder_for_upload_type(type)).to_s
423
+ end
424
+
425
+ def upload_path(type, filename)
426
+ filename = filename.to_s
427
+
428
+ unless filename.match?(/\A[A-Za-z0-9_.-]+\z/) && !filename.include?("..")
429
+ raise ArgumentError, "filename must only contain numbers, letters, hyphens, and underscores: #{filename}"
430
+ end
431
+
432
+ unless [ ".png", ".jpg", ".jpeg", ".webm" ].include?(File.extname(filename).downcase)
433
+ raise ArgumentError, "filename must have a valid extension (.png, .jpg, .jpeg, .webm): #{filename}"
434
+ end
435
+
436
+ File.join(upload_dir(type), filename)
437
+ end
438
+
439
+ def folder_for_upload_type(type)
440
+ case type
441
+ when "screenshot"
442
+ "screenshots"
443
+ when "recording"
444
+ "recordings"
445
+ end
446
+ end
447
+
448
+ def relative_path_from_root(path)
449
+ Pathname.new(path).relative_path_from(@root).to_s
450
+ end
451
+
223
452
  def validate_jsonrpc_message(message)
224
453
  return "Message must be a JSON object" unless message.is_a?(Hash)
225
454
  return "Invalid JSON-RPC version" unless message["jsonrpc"] == "2.0"
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.5.2
4
+ version: 0.8.0
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-09 00:00:00.000000000 Z
12
+ date: 2026-07-22 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: rack
@@ -41,6 +41,7 @@ files:
41
41
  - lib/tidewave/database_adapters/active_record.rb
42
42
  - lib/tidewave/database_adapters/sequel.rb
43
43
  - lib/tidewave/exceptions_middleware.rb
44
+ - lib/tidewave/magic_bytes.rb
44
45
  - lib/tidewave/quiet_requests_middleware.rb
45
46
  - lib/tidewave/railtie.rb
46
47
  - lib/tidewave/tool.rb