tidewave 0.5.1 → 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: 80a98c944044fd2164929251e642cd2c35713a3bcd2a427485aa9aed5b9c2780
4
- data.tar.gz: dac0861994231e0cfb23eb9203c43c9ed310f0fbb0041e1d674443df1e592323
3
+ metadata.gz: d8aec0fffb76b0c91651452a2d8e2b1adc3fe37681a0c778c01a4762602985ed
4
+ data.tar.gz: d0f6abae17bcc8281baed3f3db784b0243186c5008a8866a612e42bda4333e14
5
5
  SHA512:
6
- metadata.gz: 2e0cd1d84fc3aa6e2f9da2a4c1cd7dcc613e29552ed20f0b73bdcb5770bef22f5555846e290638c77bb1c5df3c1c2d53285b3fb03cd41c127394630f785e60b2
7
- data.tar.gz: 50ac0f18928027d78246fe69b0c77f552a3b05772875f8b01f016d05cffbbfeca4262359bff6407781ecb7509a99b2955788e3c1080bac110061f950c7afbd37
6
+ metadata.gz: 8b683cb4a188e66e456078a39467a10ff6bc9aa1ac3b3f2e92338eadf5cbe1b0a693e6d80dd32d6e8dfdb167dd0b83af1d1183c442fc6d8348beb036180e05ba
7
+ data.tar.gz: b3366af6bdd77c9312e8e6d70e56d898b02f75555f9b1932fea379bdb36621a8381f31df8696f86f7a6a5604885ae0ded2f22ee51a9bf323e80c7c7e52422c99
data/README.md CHANGED
@@ -1,27 +1,124 @@
1
- # Tidewave
1
+ # Tidewave Rails
2
2
 
3
- Tidewave is the coding agent for full-stack web app development. Integrate Claude Code, OpenAI Codex, and other agents with your web app and web framework at every layer, from UI to database. [See our website](https://tidewave.ai) for more information.
3
+ Tidewave Rails is an MCP server that provides runtime-level tools for developing Ruby on Rails apps using coding agents.
4
4
 
5
- This project can also be used as [a standalone Model Context Protocol server](https://hexdocs.pm/tidewave/mcp.html).
5
+ Your agent will be able to use this MCP server to talk to your running Rails app in development to:
6
+
7
+ - execute code in the context of the running app (like a Rails console for agents)
8
+ - read the app's live logs
9
+ - query your development database
10
+ - get source locations of classes and methods
11
+ - read documentation pinned to the exact gem versions your app depends on
12
+
13
+ This MCP server is an open-source component of [Tidewave](https://tidewave.ai), the agentic development environment for Rails and Phoenix.
14
+
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.
6
16
 
7
17
  ## Installation
8
18
 
9
- You can install Tidewave by running:
19
+ ### 1. Add the Tidewave gem to your app
20
+
21
+ You can add Tidewave Rails to your app by running:
10
22
 
11
23
  ```shell
12
24
  bundle add tidewave --group development
13
25
  ```
14
26
 
15
- or by manully adding the `tidewave` gem to the development group in your Gemfile:
27
+ or by manually adding the `tidewave` gem to the development group in your Gemfile:
16
28
 
17
29
  ```ruby
18
30
  gem "tidewave", group: :development
19
31
  ```
20
32
 
21
- Now make sure [Tidewave is installed](https://hexdocs.pm/tidewave/installation.html) and you are ready to connect Tidewave to your app.
33
+ ### 2. Add the Tidewave MCP to your agent/editor
34
+
35
+ Add the Tidewave MCP server to your editor or MCP client configuration as the type "http" (streamable), pointing to the `/tidewave/mcp` path and port your web application is running at. For example, `http://localhost:3000/tidewave/mcp`.
36
+
37
+ We also have specific instructions for:
38
+
39
+ - [Claude Code](https://tidewave.hexdocs.pm/mcp_claude_code.html)
40
+ - [Codex](https://tidewave.hexdocs.pm/mcp_codex.html)
41
+ - [Cursor](https://tidewave.hexdocs.pm/mcp_cursor.html)
42
+ - [Neovim](https://tidewave.hexdocs.pm/mcp_neovim.html)
43
+ - [OpenCode](https://tidewave.hexdocs.pm/mcp_opencode.html)
44
+ - [VS Code](https://tidewave.hexdocs.pm/mcp_vscode.html)
45
+ - [Zed](https://tidewave.hexdocs.pm/mcp_zed.html)
46
+ - [Others](https://tidewave.hexdocs.pm/mcp.html)
47
+
48
+ ## Usage
49
+
50
+ As with any other MCP server, your agent will call the tools exposed by the Tidewave MCP whenever it sees fit. But you can also prompt it to call them explicitly.
51
+
52
+ ## Available MCP tools
53
+
54
+ ### `project_eval`
55
+
56
+ Evaluates Ruby code in the context of your running application, with access to its runtime, loaded dependencies, and in-memory data, returning the result plus anything printed to standard output. It's like a Rails console for the agent.
57
+
58
+ [![project_eval demo](docs/assets/project_eval-poster.png)](https://asciinema.org/a/v0Bs9WtOUARzewC3)
59
+
60
+ Your agent can use it when it would rather run code than assume behavior, grounding its next step in what the running app actually does. For example, calling a method to see what comes back or reproducing a failing code path against live app state to debug it.
61
+
62
+ ### `execute_sql_query`
63
+
64
+ Runs a SQL query against your app's development database and returns the rows to the agent.
65
+
66
+ [![execute_sql_query demo](docs/assets/execute_sql_query-poster.png)](https://asciinema.org/a/qJtkEDf2YAPqjBHI)
67
+
68
+ Your agent can use it to run any SQL against your development database. For example, ask it to insert some test records to see how a page looks with realistic data. Or, after a create action, the agent can verify whether the record was saved with the expected values.
69
+
70
+ ### `get_docs`
71
+
72
+ Looks up the documentation for a class, method, or constant, reading from the exact gem versions locked in your app's Gemfile.lock.
73
+
74
+ [![get_docs demo](docs/assets/get_docs-poster.png)](https://asciinema.org/a/Aa0u915syzncFYH8)
75
+
76
+ Your agent can use it when it's unsure how a class or method works, so the code it generates is grounded in the docs for the exact versions of the gems your app uses, rather than training data that may be stale or a generic docs lookup that can't guarantee it matches the version your app depends on.
77
+
78
+ ### `get_logs`
79
+
80
+ Returns output from your running server's log.
81
+
82
+ [![get_logs demo](docs/assets/get_logs-poster.png)](https://asciinema.org/a/1260413)
83
+
84
+ Your agent can use it to see what happened after a request. For example, reading the request log and backtrace when something misbehaves, or checking the log after an action to confirm the request came in with the expected params.
85
+
86
+ ### `get_models`
87
+
88
+ Lists all of your app's models and where each one is defined, by file and line.
89
+
90
+ [![get_models demo](docs/assets/get_models-poster.png)](https://asciinema.org/a/qeELC7wEgMEI5T7n)
91
+
92
+ Your agent can use it to map the data domain and find where each model lives before opening files, rather than grepping around for class definitions.
93
+
94
+ ### `get_source_location`
95
+
96
+ Returns the file and line where a class, module, or method is defined, across both your app and its dependencies.
97
+
98
+ [![get_source_location demo](docs/assets/get_source_location-poster.png)](https://asciinema.org/a/1260415)
99
+
100
+ Your agent can use it to jump straight to where a class or method is defined, by file and line, instead of grepping for it, including when the definition lives in a gem dependency.
101
+
102
+ Also, because it resolves the location from your running app instead of parsing source text, it handles metaprogramming, where a method is generated at runtime and does not appear as a literal `def` for grep to find.
103
+
104
+ > [!NOTE]
105
+ > #### Why no tools for routes, associations, etc?
106
+ >
107
+ > Tidewave does not include tools for listing your routes, associations, etc. because
108
+ > agents are better off reading their respective source files, which gives agents more
109
+ > context and enables them to perform any necessary edit without additional tool calls.
110
+ >
111
+ > Instead, Tidewave aims to fill in missing gaps, such as evaluating code inside your
112
+ > Rails app (without starting new instances) and finding source location, which can be
113
+ > tricky, even with grepping, due to metaprogramming and the different places Bundler
114
+ > can install your dependencies.
22
115
 
23
116
  ## Troubleshooting
24
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
+
25
122
  ### Using multiple hosts/subdomains
26
123
 
27
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`:
@@ -40,7 +137,7 @@ The above will allow your application to run embedded within Tidewave across mul
40
137
 
41
138
  ### Content security policy
42
139
 
43
- 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).
44
141
 
45
142
  ### Production Environment
46
143
 
@@ -66,37 +163,7 @@ The following config is available:
66
163
 
67
164
  * `team` - set your Tidewave Team configuration, such as `config.tidewave.team = { id: "my-company" }`
68
165
 
69
- ## Available tools
70
-
71
- - `execute_sql_query` - executes a SQL query within your application
72
- database, useful for the agent to verify the result of an action
73
-
74
- - `get_docs` - get the documentation for a given module/class/method.
75
- It consults the exact versions used by the project, ensuring you always
76
- get correct information
77
-
78
- - `get_logs` - reads logs written by the server
79
-
80
- - `get_models` - lists all modules in the application and their location
81
- for quick discovery
82
-
83
- - `get_source_location` - get the source location for a given module/class/method,
84
- so an agent can directly read the source skipping search
85
-
86
- - `project_eval` - evaluates code within the Rails application itself, giving the agent
87
- access to your runtime, dependencies, and in-memory data
88
-
89
- > [!NOTE]
90
- > #### Why no tools for routes, associations, etc?
91
- >
92
- > Tidewave does not include tools for listing your routes, associations, etc. because
93
- > agents are better off reading their respective source files, which gives agents more
94
- > context and enable them to perform any necessary edit without additional tools calls.
95
- >
96
- > Instead, Tidewave aims to fill in missing gaps, such as evaluating code inside your
97
- > Rails app (without starting new instances) and finding source location, which can be
98
- > tricky, even with grepping, due to meta-programming and the different places Bundler
99
- > can install your dependencies.
166
+ * `toolbar` - controls whether the Tidewave toolbar is injected into HTML pages. Defaults to `true`
100
167
 
101
168
  ## Acknowledgements
102
169
 
@@ -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.1"
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,22 +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
- not_found
137
+ # The MCP Streamable HTTP transport requires the MCP endpoint to answer
138
+ # non-POST methods with 405 (GET without SSE support, DELETE, etc.)
139
+ path == [ TIDEWAVE_ROUTE, MCP_ROUTE ] ? method_not_allowed() : not_found()
76
140
  end
77
141
  else
78
- strip_x_frame_options(@app.call(env))
142
+ inject_toolbar(request, strip_x_frame_options(@app.call(env)))
79
143
  end
80
144
  end
81
145
 
@@ -83,7 +147,7 @@ class Tidewave
83
147
 
84
148
  def strip_x_frame_options(response)
85
149
  status, headers, body = response
86
- headers.delete("X-Frame-Options")
150
+ headers.delete("x-frame-options")
87
151
  [ status, headers, body ]
88
152
  end
89
153
 
@@ -104,28 +168,66 @@ class Tidewave
104
168
  [ 200, response_headers("text/html", body), [ body ] ]
105
169
  end
106
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
+
107
190
  def config_endpoint(request)
108
- json_response(config_data(request), headers: { "Access-Control-Allow-Origin" => "*" })
191
+ json_response(config_data(request), headers: { "access-control-allow-origin" => "*" })
109
192
  end
110
193
 
111
194
  def mcp_endpoint(request)
112
- body = request.body.read
195
+ message = JSON.parse(request.body.read)
113
196
 
114
- message = JSON.parse(body)
115
- validation_error = validate_jsonrpc_message(message)
116
- return jsonrpc_error_response(nil, -32600, validation_error) if validation_error
117
-
118
- response = handle_mcp_message(message)
119
- return json_response({ "status" => "ok" }, status: 202) if response.nil?
120
-
121
- json_response(response)
197
+ if message.is_a?(Array)
198
+ handle_mcp_batch(message)
199
+ else
200
+ handle_mcp_single(message)
201
+ end
122
202
  rescue JSON::ParserError
123
- jsonrpc_error_response(nil, -32700, "Parse error")
203
+ jsonrpc_error_response(nil, -32700, "Parse error", status: 400)
124
204
  rescue StandardError => error
125
205
  @logger&.error("Error handling MCP request: #{error.message}")
126
206
  jsonrpc_error_response(nil, -32603, "Internal error")
127
207
  end
128
208
 
209
+ def handle_mcp_single(message)
210
+ validation_error = validate_jsonrpc_message(message)
211
+ return jsonrpc_error_response(nil, -32600, validation_error, status: 400) if validation_error
212
+
213
+ response = handle_mcp_message(message)
214
+ response.nil? ? accepted_response : json_response(response)
215
+ end
216
+
217
+ def handle_mcp_batch(messages)
218
+ return jsonrpc_error_response(nil, -32600, "Invalid Request", status: 400) if messages.empty?
219
+
220
+ responses = messages.map { |message| handle_mcp_batch_message(message) }.compact
221
+ responses.empty? ? accepted_response : json_response(responses)
222
+ end
223
+
224
+ def handle_mcp_batch_message(message)
225
+ validation_error = validate_jsonrpc_message(message)
226
+ return jsonrpc_error_response_body(nil, -32600, validation_error) if validation_error
227
+
228
+ handle_mcp_message(message)
229
+ end
230
+
129
231
  def config_data(request)
130
232
  {
131
233
  "project_name" => @options[:project_name],
@@ -133,8 +235,90 @@ class Tidewave
133
235
  "orm_adapter" => @options[:orm_adapter],
134
236
  "team" => @options[:team] || {},
135
237
  "tidewave_version" => VERSION,
136
- "local_port" => local_port(request)
238
+ "local_port" => local_port(request),
239
+ "tmp_dir" => TMP_DIR
240
+ }
241
+ end
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" => {}
137
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)
138
322
  end
139
323
 
140
324
  def json_response(payload, status: 200, headers: {})
@@ -151,19 +335,37 @@ class Tidewave
151
335
  text_response(404, "Not Found")
152
336
  end
153
337
 
338
+ def method_not_allowed
339
+ status, headers, body = text_response(405, "Method Not Allowed")
340
+ [ status, headers.merge("allow" => "POST"), body ]
341
+ end
342
+
343
+ def accepted_response
344
+ [ 202, { "content-length" => "0" }, [] ]
345
+ end
346
+
154
347
  def text_response(status, message)
155
348
  [ status, response_headers("text/plain; charset=utf-8", message), [ message ] ]
156
349
  end
157
350
 
351
+ # Rack 3 requires response header keys to be lowercase. Capitalized keys break
352
+ # case-sensitive middleware such as Rack::Deflater, which strips "content-length"
353
+ # before gzipping; a surviving "Content-Length" leaves a stale (uncompressed)
354
+ # length on the compressed body and hangs spec-compliant HTTP clients.
158
355
  def response_headers(content_type, body)
159
356
  {
160
- "Content-Type" => content_type,
161
- "Content-Length" => body.bytesize.to_s
357
+ "content-type" => content_type,
358
+ "content-length" => body.bytesize.to_s
162
359
  }
163
360
  end
164
361
 
165
362
  def origin_allowed_path?(path)
166
- 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)
167
369
  end
168
370
 
169
371
  def local_port(request)
@@ -186,13 +388,74 @@ class Tidewave
186
388
  false
187
389
  end
188
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
+
189
452
  def validate_jsonrpc_message(message)
190
453
  return "Message must be a JSON object" unless message.is_a?(Hash)
191
454
  return "Invalid JSON-RPC version" unless message["jsonrpc"] == "2.0"
192
455
 
193
456
  has_id = message.key?("id")
194
457
  has_method = message.key?("method")
195
- has_result = message.key?("result")
458
+ has_result = message.key?("result") || message.key?("error")
196
459
 
197
460
  return nil if has_method
198
461
  return nil if has_id && has_result
@@ -200,14 +463,17 @@ class Tidewave
200
463
  "Invalid JSON-RPC message structure"
201
464
  end
202
465
 
466
+ # Returns the JSON-RPC response for a request, or nil for messages that
467
+ # must not be replied to (notifications and client-sent responses), which
468
+ # the transport acknowledges with 202 Accepted.
203
469
  def handle_mcp_message(message)
470
+ return nil unless message.key?("method") && message.key?("id")
471
+
204
472
  method = message["method"]
205
473
  request_id = message["id"]
206
474
  params = message["params"].is_a?(Hash) ? message["params"] : {}
207
475
 
208
476
  case method
209
- when "notifications/initialized", "notifications/cancelled"
210
- nil
211
477
  when "ping"
212
478
  jsonrpc_success_response_body(request_id, {})
213
479
  when "initialize"
@@ -216,6 +482,12 @@ class Tidewave
216
482
  jsonrpc_success_response_body(request_id, { "tools" => tool_definitions })
217
483
  when "tools/call"
218
484
  handle_tool_call(request_id, params)
485
+ when "prompts/list"
486
+ jsonrpc_success_response_body(request_id, { "prompts" => [] })
487
+ when "resources/list"
488
+ jsonrpc_success_response_body(request_id, { "resources" => [] })
489
+ when "resources/templates/list"
490
+ jsonrpc_success_response_body(request_id, { "resourceTemplates" => [] })
219
491
  else
220
492
  {
221
493
  "jsonrpc" => "2.0",
@@ -233,14 +505,9 @@ class Tidewave
233
505
  client_version = params["protocolVersion"]
234
506
  return jsonrpc_error_response_body(request_id, -32602, "Protocol version is required") if client_version.nil? || client_version.empty?
235
507
 
236
- if client_version < PROTOCOL_VERSION
237
- return jsonrpc_error_response_body(
238
- request_id,
239
- -32602,
240
- "Unsupported protocol version. Server supports #{PROTOCOL_VERSION} or later"
241
- )
242
- end
243
-
508
+ # Version negotiation: when the client requests a version we don't
509
+ # support, we respond with the version we do support and the client
510
+ # decides whether to continue or disconnect.
244
511
  jsonrpc_success_response_body(request_id, {
245
512
  "protocolVersion" => PROTOCOL_VERSION,
246
513
  "capabilities" => { "tools" => { "listChanged" => false } },
@@ -276,8 +543,8 @@ class Tidewave
276
543
  }
277
544
  end
278
545
 
279
- def jsonrpc_error_response(request_id, code, message)
280
- json_response(jsonrpc_error_response_body(request_id, code, message))
546
+ def jsonrpc_error_response(request_id, code, message, status: 200)
547
+ json_response(jsonrpc_error_response_body(request_id, code, message), status: status)
281
548
  end
282
549
 
283
550
  def jsonrpc_error_response_body(request_id, code, message)
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.1
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-06-14 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