tidewave 0.3.0 → 0.4.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: d140360315f54166fc14e330f33fc5dd62e561c49ba3d6cb5044a8e7b5247e41
4
- data.tar.gz: c69a1a0363ce0f50a396599f2b551896a602431929731dd9fbf4fbe283823cf1
3
+ metadata.gz: 728311a2ea0525028ccf9209d06bc1079f9258e35d534409f4ac867ff5212d32
4
+ data.tar.gz: 11c1b092dc830897e59cb39f71de745b840acfd6f8845bf058c9010a06258e75
5
5
  SHA512:
6
- metadata.gz: 6f3203bbce1c31681fcb2b21575ea62a8714a469f61ec30be8d59ccd22028592b7a33317b4f434e0d7a4051caaf1db1aa82247d395a961d4a09f1a1538cc6cd8
7
- data.tar.gz: 968742eed4690b5c2a1f8c0ecf233e4742a19bc2ac8a700d4f64eca2878bea8f70d1d2228cffc472019462790eb122601b7acfc608fd7950c100974f3c9053d4
6
+ metadata.gz: f9b49181b3f571b668de00287f0fec47088256ee335e31036385cedcb3b8c34274529d70a4f21d9c114279d9a1f5e52a56cab75b8465546863e0fd539fd1b390
7
+ data.tar.gz: 1201a00a28892246592c158bc0e90cb5a6ce5d69a6a3533ef81bb8c853d2124d3544d029e8346613fa4d099010869f83cae49bcad82d890d1e935ee9f54903f4
data/README.md CHANGED
@@ -29,7 +29,7 @@ If you want to use Docker for development, you either need to enable the configu
29
29
 
30
30
  ### Content security policy
31
31
 
32
- 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.
32
+ 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.
33
33
 
34
34
  ### Web server requirements
35
35
 
@@ -55,9 +55,11 @@ The following config is available:
55
55
 
56
56
  * `allow_remote_access` - Tidewave only allows requests from localhost by default, even if your server listens on other interfaces as well. If you trust your network and need to access Tidewave from a different machine, this configuration can be set to `true`
57
57
 
58
+ * `logger_middleware` - The logger middleware Tidewave should wrap to silence its own logs
59
+
58
60
  * `preferred_orm` - which ORM to use, either `:active_record` (default) or `:sequel`
59
61
 
60
- * `team` - set your team configuration, such as `config.tidewave.team = { id: "my-company }`
62
+ * `team` - set your Tidewave Team configuration, such as `config.tidewave.team = { id: "my-company" }`
61
63
 
62
64
  ## Acknowledgements
63
65
 
@@ -2,15 +2,16 @@
2
2
 
3
3
  module Tidewave
4
4
  class Configuration
5
- attr_accessor :logger, :allow_remote_access, :preferred_orm, :credentials, :client_url, :team
5
+ attr_accessor :logger, :allow_remote_access, :preferred_orm, :dev, :client_url, :team, :logger_middleware
6
6
 
7
7
  def initialize
8
8
  @logger = nil
9
9
  @allow_remote_access = true
10
10
  @preferred_orm = :active_record
11
- @credentials = {}
11
+ @dev = false
12
12
  @client_url = "https://tidewave.ai"
13
13
  @team = {}
14
+ @logger_middleware = nil
14
15
  end
15
16
  end
16
17
  end
@@ -26,8 +26,8 @@ module Tidewave
26
26
  raise NotImplementedError, "Subclasses must implement execute_query"
27
27
  end
28
28
 
29
- def get_base_class
30
- raise NotImplementedError, "Subclasses must implement get_base_class"
29
+ def get_models
30
+ raise NotImplementedError, "Subclasses must implement get_models"
31
31
  end
32
32
  end
33
33
  end
@@ -25,8 +25,8 @@ module Tidewave
25
25
  }
26
26
  end
27
27
 
28
- def get_base_class
29
- ::ActiveRecord::Base
28
+ def get_models
29
+ ::ActiveRecord::Base.descendants
30
30
  end
31
31
 
32
32
  private
@@ -29,8 +29,9 @@ module Tidewave
29
29
  }
30
30
  end
31
31
 
32
- def get_base_class
33
- ::Sequel::Model
32
+ def get_models
33
+ # Filter out anonymous Sequel models that can't be resolved as constants
34
+ ::Sequel::Model.descendants.reject { |model| model.name&.start_with?("Sequel::_Model(") }
34
35
  end
35
36
  end
36
37
  end
@@ -8,13 +8,13 @@ require "active_support/core_ext/class"
8
8
  require "active_support/core_ext/object/blank"
9
9
  require "json"
10
10
  require "erb"
11
+ require_relative "streamable_http_transport"
11
12
 
12
13
  class Tidewave::Middleware
13
14
  TIDEWAVE_ROUTE = "tidewave".freeze
14
- EMPTY_ROUTE = "empty".freeze
15
- SSE_ROUTE = "mcp".freeze
16
- MESSAGES_ROUTE = "mcp/message".freeze
15
+ MCP_ROUTE = "mcp".freeze
17
16
  SHELL_ROUTE = "shell".freeze
17
+ CONFIG_ROUTE = "config".freeze
18
18
 
19
19
  INVALID_IP = <<~TEXT.freeze
20
20
  For security reasons, Tidewave does not accept remote connections by default.
@@ -31,9 +31,8 @@ class Tidewave::Middleware
31
31
  @app = FastMcp.rack_middleware(app,
32
32
  name: "tidewave",
33
33
  version: Tidewave::VERSION,
34
- path_prefix: "/" + TIDEWAVE_ROUTE,
35
- messages_route: MESSAGES_ROUTE,
36
- sse_route: SSE_ROUTE,
34
+ path_prefix: "/" + TIDEWAVE_ROUTE + "/" + MCP_ROUTE,
35
+ transport: Tidewave::StreamableHttpTransport,
37
36
  logger: config.logger || Logger.new(Rails.root.join("log", "tidewave.log")),
38
37
  # Rails runs the HostAuthorization in dev, so we skip this
39
38
  allowed_origins: [],
@@ -63,25 +62,26 @@ class Tidewave::Middleware
63
62
  case [ request.request_method, path ]
64
63
  when [ "GET", [ TIDEWAVE_ROUTE ] ]
65
64
  return home(request)
66
- when [ "GET", [ TIDEWAVE_ROUTE, EMPTY_ROUTE ] ]
67
- return empty(request)
65
+ when [ "GET", [ TIDEWAVE_ROUTE, CONFIG_ROUTE ] ]
66
+ return config_endpoint(request)
68
67
  when [ "POST", [ TIDEWAVE_ROUTE, SHELL_ROUTE ] ]
69
68
  return shell(request)
70
69
  end
71
70
  end
72
71
 
73
- @app.call(env)
72
+ status, headers, body = @app.call(env)
73
+
74
+ # Remove X-Frame-Options headers for non-Tidewave routes to allow embedding.
75
+ # CSP headers are configured in the CSP application environment.
76
+ headers.delete("X-Frame-Options")
77
+
78
+ [ status, headers, body ]
74
79
  end
75
80
 
76
81
  private
77
82
 
78
83
  def home(request)
79
- config = {
80
- "project_name" => @project_name,
81
- "framework_type" => "rails",
82
- "tidewave_version" => Tidewave::VERSION,
83
- "team" => @team
84
- }
84
+ config = config_data
85
85
 
86
86
  html = <<~HTML
87
87
  <html>
@@ -98,9 +98,17 @@ class Tidewave::Middleware
98
98
  [ 200, { "Content-Type" => "text/html" }, [ html ] ]
99
99
  end
100
100
 
101
- def empty(request)
102
- html = ""
103
- [ 200, { "Content-Type" => "text/html" }, [ html ] ]
101
+ def config_endpoint(request)
102
+ [ 200, { "Content-Type" => "application/json" }, [ JSON.generate(config_data) ] ]
103
+ end
104
+
105
+ def config_data
106
+ {
107
+ "project_name" => @project_name,
108
+ "framework_type" => "rails",
109
+ "tidewave_version" => Tidewave::VERSION,
110
+ "team" => @team
111
+ }
104
112
  end
105
113
 
106
114
  def forbidden(message)
@@ -32,6 +32,8 @@ module Tidewave
32
32
  content_security_policy.directives["script-src"].try do |script_src|
33
33
  script_src << "'unsafe-eval'" unless script_src.include?("'unsafe-eval'")
34
34
  end
35
+
36
+ content_security_policy.directives.delete("frame-ancestors")
35
37
  end
36
38
  end
37
39
  end
@@ -39,7 +41,6 @@ module Tidewave
39
41
  initializer "tidewave.intercept_exceptions" do |app|
40
42
  # We intercept exceptions from DebugExceptions, format the
41
43
  # information as text and inject into the exception page html.
42
-
43
44
  ActionDispatch::DebugExceptions.register_interceptor do |request, exception|
44
45
  request.set_header("tidewave.exception", exception)
45
46
  end
@@ -49,7 +50,8 @@ module Tidewave
49
50
 
50
51
  initializer "tidewave.logging" do |app|
51
52
  # Do not pollute user logs with tidewave requests.
52
- app.middleware.insert_before(Rails::Rack::Logger, Tidewave::QuietRequestsMiddleware)
53
+ logger_middleware = app.config.tidewave.logger_middleware || Rails::Rack::Logger
54
+ app.middleware.insert_before(logger_middleware, Tidewave::QuietRequestsMiddleware)
53
55
  end
54
56
  end
55
57
  end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "rack"
5
+ require "fast_mcp"
6
+
7
+ module Tidewave
8
+ # Streamable HTTP transport for MCP (POST-only, no SSE)
9
+ # This transport implements a simplified version of the MCP Streamable HTTP protocol
10
+ # that only supports POST requests for JSON-RPC messages. Unlike the full protocol,
11
+ # it does not support Server-Sent Events (SSE) for streaming responses.
12
+ class StreamableHttpTransport < FastMcp::Transports::BaseTransport
13
+ attr_reader :app, :path
14
+
15
+ def initialize(app, server, options = {})
16
+ super(server, logger: options[:logger])
17
+ @app = app
18
+ @path = options[:path_prefix] || "/mcp"
19
+ @running = false
20
+ end
21
+
22
+ def start
23
+ @logger.debug("Starting Streamable HTTP transport (POST-only) at path: #{@path}")
24
+ @running = true
25
+ end
26
+
27
+ def stop
28
+ @logger.debug("Stopping Streamable HTTP transport")
29
+ @running = false
30
+ end
31
+
32
+ # Send a message - capture response for synchronous HTTP
33
+ # Required by FastMCP::Transports::BaseTransport interface
34
+ def send_message(message)
35
+ @logger.debug("send_message called, capturing response: #{message.inspect}")
36
+ @captured_response = message
37
+ end
38
+
39
+ def call(env)
40
+ request = Rack::Request.new(env)
41
+
42
+ if request.path == @path
43
+ @server.transport = self
44
+ handle_mcp_request(request, env)
45
+ else
46
+ @app.call(env)
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ def handle_mcp_request(request, env)
53
+ if request.post?
54
+ handle_post_request(request)
55
+ else
56
+ method_not_allowed_response
57
+ end
58
+ end
59
+
60
+ def handle_post_request(request)
61
+ @logger.debug("Received POST request to MCP endpoint")
62
+
63
+ begin
64
+ body = request.body.read
65
+ message = JSON.parse(body)
66
+
67
+ @logger.debug("Processing message: #{message.inspect}")
68
+
69
+ unless valid_jsonrpc_message?(message)
70
+ return json_rpc_error_response(400, -32600, "Invalid Request", nil)
71
+ end
72
+
73
+ # Capture the response that will be sent via send_message
74
+ @captured_response = nil
75
+ @server.handle_json_request(message)
76
+
77
+ @logger.debug("Sending response: #{@captured_response.inspect}")
78
+
79
+ [
80
+ 200,
81
+ { "Content-Type" => "application/json" },
82
+ [ JSON.generate(@captured_response) ]
83
+ ]
84
+ rescue JSON::ParserError => e
85
+ @logger.error("Invalid JSON in request: #{e.message}")
86
+ json_rpc_error_response(400, -32700, "Parse error", nil)
87
+ rescue => e
88
+ @logger.error("Error processing message: #{e.message}")
89
+ @logger.error(e.backtrace.join("\n")) if e.backtrace
90
+ json_rpc_error_response(500, -32603, "Internal error", nil)
91
+ end
92
+ end
93
+
94
+ def valid_jsonrpc_message?(message)
95
+ return false unless message.is_a?(Hash)
96
+ return false unless message["jsonrpc"] == "2.0"
97
+
98
+ message.key?("method") || message.key?("result") || message.key?("error")
99
+ end
100
+
101
+ def method_not_allowed_response
102
+ [
103
+ 405,
104
+ { "Content-Type" => "application/json" },
105
+ [ JSON.generate({
106
+ jsonrpc: "2.0",
107
+ error: {
108
+ code: -32601,
109
+ message: "Method not allowed. This endpoint only supports POST requests."
110
+ },
111
+ id: nil
112
+ }) ]
113
+ ]
114
+ end
115
+
116
+ def json_rpc_error_response(http_status, code, message, id)
117
+ [
118
+ http_status,
119
+ { "Content-Type" => "application/json" },
120
+ [ JSON.generate({
121
+ jsonrpc: "2.0",
122
+ error: {
123
+ code: code,
124
+ message: message
125
+ },
126
+ id: id
127
+ }) ]
128
+ ]
129
+ end
130
+ end
131
+ end
@@ -22,6 +22,12 @@ class Tidewave::Tools::ExecuteSqlQuery < Tidewave::Tools::Base
22
22
  optional(:arguments).value(:array).description("The arguments to pass to the query. The query must contain corresponding parameter placeholders.")
23
23
  end
24
24
 
25
+ def @input_schema.json_schema
26
+ schema = super
27
+ schema[:properties][:arguments][:items] = {}
28
+ schema
29
+ end
30
+
25
31
  RESULT_LIMIT = 50
26
32
 
27
33
  def call(query:, arguments: [])
@@ -17,13 +17,63 @@ class Tidewave::Tools::GetLogs < Tidewave::Tools::Base
17
17
  log_file = Rails.root.join("log", "#{Rails.env}.log")
18
18
  return "Log file not found" unless File.exist?(log_file)
19
19
 
20
- logs = File.readlines(log_file)
20
+ regex = Regexp.new(grep, Regexp::IGNORECASE) if grep
21
+ matching_lines = []
21
22
 
22
- if grep
23
- regex = Regexp.new(grep, Regexp::IGNORECASE)
24
- logs = logs.select { |line| line.match?(regex) }
23
+ tail_lines(log_file) do |line|
24
+ if regex.nil? || line.match?(regex)
25
+ matching_lines.unshift(line)
26
+ break if matching_lines.size >= tail
27
+ end
25
28
  end
26
29
 
27
- logs.last(tail).join
30
+ matching_lines.join
31
+ end
32
+
33
+ private
34
+
35
+ def tail_lines(file_path)
36
+ File.open(file_path, "rb") do |file|
37
+ file.seek(0, IO::SEEK_END)
38
+ file_size = file.pos
39
+ return if file_size == 0
40
+
41
+ buffer_size = [ 4096, file_size ].min
42
+ pos = file_size
43
+ buffer = ""
44
+
45
+ while pos > 0 && buffer.count("\n") < 10000 # Safety limit
46
+ # Move back by buffer_size or to beginning of file
47
+ seek_pos = [ pos - buffer_size, 0 ].max
48
+ file.seek(seek_pos)
49
+
50
+ # Read chunk
51
+ chunk = file.read(pos - seek_pos)
52
+ buffer = chunk + buffer
53
+ pos = seek_pos
54
+
55
+ # Extract complete lines from buffer
56
+ lines = buffer.split("\n")
57
+
58
+ # Keep the first partial line (if any) for next iteration
59
+ if pos > 0 && !buffer.start_with?("\n")
60
+ buffer = lines.shift || ""
61
+ else
62
+ buffer = ""
63
+ end
64
+
65
+ # Yield lines in reverse order (last to first)
66
+ lines.reverse_each do |line|
67
+ yield line + "\n" unless line.empty?
68
+ end
69
+
70
+ break if pos == 0
71
+ end
72
+
73
+ # Handle any remaining buffer content
74
+ unless buffer.empty?
75
+ yield buffer + "\n"
76
+ end
77
+ end
28
78
  end
29
79
  end
@@ -10,8 +10,10 @@ class Tidewave::Tools::GetModels < Tidewave::Tools::Base
10
10
  # Ensure all models are loaded
11
11
  Rails.application.eager_load!
12
12
 
13
- base_class = Tidewave::DatabaseAdapter.current.get_base_class
14
- base_class.descendants.map do |model|
13
+ # Use adapter to get models (encapsulates ORM-specific logic)
14
+ models = Tidewave::DatabaseAdapter.current.get_models
15
+
16
+ models.map do |model|
15
17
  if location = get_relative_source_location(model.name)
16
18
  "* #{model.name} at #{location}"
17
19
  else
@@ -23,6 +23,12 @@ class Tidewave::Tools::ProjectEval < Tidewave::Tools::Base
23
23
  optional(:json).hidden().filled(:bool).description("Whether to return the result as JSON with structured output containing result, success, stdout, and stderr fields. Defaults to false.")
24
24
  end
25
25
 
26
+ def @input_schema.json_schema
27
+ schema = super
28
+ schema[:properties][:arguments][:items] = {}
29
+ schema
30
+ end
31
+
26
32
  def call(code:, arguments: [], timeout: 30_000, json: false)
27
33
  original_stdout = $stdout
28
34
  original_stderr = $stderr
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tidewave
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
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.3.0
4
+ version: 0.4.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: 2025-09-08 00:00:00.000000000 Z
12
+ date: 2025-10-17 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: rails
@@ -31,14 +31,14 @@ dependencies:
31
31
  requirements:
32
32
  - - "~>"
33
33
  - !ruby/object:Gem::Version
34
- version: 1.5.0
34
+ version: 1.6.0
35
35
  type: :runtime
36
36
  prerelease: false
37
37
  version_requirements: !ruby/object:Gem::Requirement
38
38
  requirements:
39
39
  - - "~>"
40
40
  - !ruby/object:Gem::Version
41
- version: 1.5.0
41
+ version: 1.6.0
42
42
  - !ruby/object:Gem::Dependency
43
43
  name: rack
44
44
  requirement: !ruby/object:Gem::Requirement
@@ -72,6 +72,7 @@ files:
72
72
  - lib/tidewave/middleware.rb
73
73
  - lib/tidewave/quiet_requests_middleware.rb
74
74
  - lib/tidewave/railtie.rb
75
+ - lib/tidewave/streamable_http_transport.rb
75
76
  - lib/tidewave/tools/base.rb
76
77
  - lib/tidewave/tools/execute_sql_query.rb
77
78
  - lib/tidewave/tools/get_docs.rb