tidewave 0.4.2 → 0.5.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: 2ecc3b3a79123c50c7ec933b8cea5e2e4f61aff33671db1237c69a7536b31071
4
- data.tar.gz: 14781db2f527923f2c5a52c4830c055659abd2e06d49526dd4630df0cd63d22a
3
+ metadata.gz: d75b5659655095637ad8830051ef50f06e018339520d093c51854cec9c320fd7
4
+ data.tar.gz: 9325b6a4ea7ac4e1444a677bd1dc785bc6f8046e8492a5d1b70267c9424ca9a3
5
5
  SHA512:
6
- metadata.gz: 39710ecba05ce8a3bf8686695c4602282fd6ee27356f4cc532cb78ea57a16403d2eb60059163b21ed5b3a3cc9225ae51a32a18df7eeee5b6e70197aae246ab26
7
- data.tar.gz: e87dccaff2712bb7df136913ab409d9cc818f81444fb3892025541a274ba4ba4f17986bc425ba10c99e414668a7531a5773a8f5043c27b58b1af2d0c53a03538
6
+ metadata.gz: 9fe7d353f945a3a3735dfdd56486188df12034736c2ac78561dca478b05b6f15fba9608dd8e8209f3d9a66659f4908c2814ec65ccdf13030f56b1387a9190fde
7
+ data.tar.gz: 6782f79c55813d99d5cffeb973812116aaf581570119de0ebcd739d2f65d1aa0fbc3ed3bc1cc1d478ec0b30ac18fbc2ab9db6037df18ec433b8ef50cce7eb947
data/README.md CHANGED
@@ -20,6 +20,14 @@ gem "tidewave", group: :development
20
20
 
21
21
  Now make sure [Tidewave is installed](https://hexdocs.pm/tidewave/installation.html) and you are ready to connect Tidewave to your app.
22
22
 
23
+ ## Development
24
+
25
+ Run the Minitest suite with:
26
+
27
+ ```shell
28
+ bundle exec ruby -Itest test/all_test.rb
29
+ ```
30
+
23
31
  ## Troubleshooting
24
32
 
25
33
  ### Using multiple hosts/subdomains
@@ -58,7 +66,7 @@ You may configure `tidewave` using the following syntax:
58
66
 
59
67
  The following config is available:
60
68
 
61
- * `allow_remote_access` - Tidewave only allows requests from localhost by default, even if your server listens on other interfaces. If you trust your network and need to access Tidewave from a different machine, this configuration can be set to `true`
69
+ * `allow_remote_access` - Tidewave only allows requests from localhost by default, even if your server listens on other interfaces, for security purposes. Read [our security guidelines for more information and when to allow remote access](https://hexdocs.pm/tidewave/security.html) (if you know what you are doing)
62
70
 
63
71
  * `logger_middleware` - The logger middleware Tidewave should wrap to silence its own logs
64
72
 
@@ -88,7 +96,7 @@ The following config is available:
88
96
 
89
97
  ## Acknowledgements
90
98
 
91
- A thank you to Yorick Jacquin, for creating [FastMCP](https://github.com/yjacquin/fast_mcp) and implementing the initial version of this project.
99
+ A thank you to Yorick Jacquin for the initial version of this project.
92
100
 
93
101
  ## License
94
102
 
@@ -1,12 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Tidewave
3
+ class Tidewave
4
4
  class Configuration
5
5
  attr_accessor :logger, :allow_remote_access, :preferred_orm, :dev, :client_url, :team, :logger_middleware
6
6
 
7
7
  def initialize
8
- @logger = nil
8
+ # Rails has a hosts middleware which already checks for this
9
9
  @allow_remote_access = true
10
+ @logger = nil
10
11
  @preferred_orm = :active_record
11
12
  @dev = false
12
13
  @client_url = "https://tidewave.ai"
@@ -1,14 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Tidewave
3
+ class Tidewave
4
4
  class DatabaseAdapter
5
5
  class << self
6
- def current
7
- @current ||= create_adapter
8
- end
9
-
10
- def create_adapter
11
- orm_type = Rails.application.config.tidewave.preferred_orm
6
+ def for(orm_type)
12
7
  case orm_type
13
8
  when :active_record
14
9
  require_relative "database_adapters/active_record"
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Tidewave
3
+ class Tidewave
4
4
  module DatabaseAdapters
5
5
  class ActiveRecord < DatabaseAdapter
6
6
  RESULT_LIMIT = 50
@@ -21,19 +21,13 @@ module Tidewave
21
21
  rows: result.rows.first(RESULT_LIMIT),
22
22
  row_count: result.rows.length,
23
23
  adapter: conn.adapter_name,
24
- database: database_name
24
+ database: conn.pool.db_config.database
25
25
  }
26
26
  end
27
27
 
28
28
  def get_models
29
29
  ::ActiveRecord::Base.descendants
30
30
  end
31
-
32
- private
33
-
34
- def database_name
35
- Rails.configuration.database_configuration.dig(Rails.env, "database")
36
- end
37
31
  end
38
32
  end
39
33
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Tidewave
3
+ class Tidewave
4
4
  module DatabaseAdapters
5
5
  class Sequel < DatabaseAdapter
6
6
  RESULT_LIMIT = 50
@@ -31,7 +31,19 @@ module Tidewave
31
31
 
32
32
  def get_models
33
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
+ descendants_of(::Sequel::Model).reject { |model| model.name&.start_with?("Sequel::_Model(") }
35
+ end
36
+
37
+ private
38
+
39
+ def descendants_of(base)
40
+ if base.respond_to?(:descendants)
41
+ base.descendants
42
+ elsif base.respond_to?(:subclasses)
43
+ base.subclasses.flat_map { |subclass| [ subclass ] + descendants_of(subclass) }
44
+ else
45
+ []
46
+ end
35
47
  end
36
48
  end
37
49
  end
@@ -1,37 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "logger"
4
- require "fileutils"
5
4
  require "tidewave/configuration"
6
- require "tidewave/middleware"
7
5
  require "tidewave/exceptions_middleware"
8
6
  require "tidewave/quiet_requests_middleware"
9
7
 
10
- gem_tools_path = File.expand_path("tools/**/*.rb", __dir__)
11
- Dir[gem_tools_path].each { |f| require f }
12
-
13
- # Temporary monkey patching to address regression in FastMCP
14
- if Dry::Schema::Macros::Hash.method_defined?(:original_call)
15
- Dry::Schema::Macros::Hash.class_eval do
16
- def call(*args, &block)
17
- if block
18
- # Use current context to track nested context if available
19
- context = MetadataContext.current
20
- if context
21
- context.with_nested(name) do
22
- original_call(*args, &block)
23
- end
24
- else
25
- original_call(*args, &block)
26
- end
27
- else
28
- original_call(*args)
29
- end
30
- end
31
- end
32
- end
33
-
34
- module Tidewave
8
+ class Tidewave
35
9
  class Railtie < Rails::Railtie
36
10
  config.tidewave = Tidewave::Configuration.new()
37
11
 
@@ -40,10 +14,21 @@ module Tidewave
40
14
  raise "For security reasons, Tidewave is only supported in environments where config.enable_reloading is true (typically development)"
41
15
  end
42
16
 
17
+ tidewave_config = app.config.tidewave
18
+
43
19
  app.config.middleware.insert_after(
44
20
  ActionDispatch::Callbacks,
45
- Tidewave::Middleware,
46
- app.config.tidewave
21
+ Tidewave,
22
+ allow_remote_access: tidewave_config.allow_remote_access,
23
+ client_url: tidewave_config.client_url,
24
+ framework_type: "rails",
25
+ project_name: app.class.module_parent.name,
26
+ team: tidewave_config.team,
27
+ logger: tidewave_config.logger || Rails.logger,
28
+ root: Rails.root,
29
+ log_file: Rails.root.join("log", "#{Rails.env}.log"),
30
+ orm_adapter: tidewave_config.preferred_orm,
31
+ before_reload: -> { app.eager_load! }
47
32
  )
48
33
 
49
34
  app.config.after_initialize do
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Tidewave
4
+ class Tool
5
+ class << self
6
+ def descendants
7
+ @descendants ||= []
8
+ end
9
+
10
+ def inherited(subclass)
11
+ descendants << subclass
12
+ super
13
+ end
14
+ end
15
+
16
+ def initialize(_options = {})
17
+ end
18
+
19
+ def definition
20
+ raise NotImplementedError, "#{self.class} must implement #definition"
21
+ end
22
+
23
+ def call(_arguments = {})
24
+ raise NotImplementedError, "#{self.class} must implement #call"
25
+ end
26
+
27
+ def validate_and_call(arguments)
28
+ arguments ||= {}
29
+
30
+ unless arguments.is_a?(Hash)
31
+ raise ArgumentError, "Invalid arguments: expected an object"
32
+ end
33
+
34
+ # This validator intentionally enforces only a small subset of JSON Schema:
35
+ # `type`, `required`, and scalar `default` values. Other keywords such as
36
+ # `minLength`, `maxLength`, `enum`, and `pattern` remain descriptive until
37
+ # Tidewave grows broader schema support.
38
+ validate_schema(arguments, definition.fetch("inputSchema", {}))
39
+ call(arguments)
40
+ end
41
+
42
+ private
43
+
44
+ def validate_schema(value, schema, path = nil)
45
+ return unless schema.is_a?(Hash)
46
+
47
+ validate_type(value, schema["type"], path) if schema["type"]
48
+
49
+ case schema["type"]
50
+ when "object"
51
+ validate_object(value, schema, path)
52
+ when "array"
53
+ validate_array(value, schema, path)
54
+ end
55
+ end
56
+
57
+ def validate_object(value, schema, path)
58
+ properties = schema.fetch("properties", {})
59
+
60
+ properties.each do |name, property_schema|
61
+ if !value.key?(name) && property_schema.is_a?(Hash) && property_schema.key?("default")
62
+ validate_default(property_schema["default"], property_path(path, name))
63
+ value[name] = property_schema["default"]
64
+ end
65
+
66
+ next unless value.key?(name)
67
+
68
+ validate_schema(value[name], property_schema, property_path(path, name))
69
+ end
70
+
71
+ validate_required_properties(value, schema.fetch("required", []), path)
72
+ end
73
+
74
+ def validate_array(value, schema, path)
75
+ item_schema = schema["items"]
76
+ return unless item_schema.is_a?(Hash) && !item_schema.empty?
77
+
78
+ value.each_with_index do |item, index|
79
+ validate_schema(item, item_schema, "#{path || 'value'}[#{index}]")
80
+ end
81
+ end
82
+
83
+ def validate_required_properties(value, required_properties, path)
84
+ required_properties.each do |name|
85
+ next if value.key?(name)
86
+
87
+ raise ArgumentError, "Invalid arguments: missing required property '#{property_path(path, name)}'"
88
+ end
89
+ end
90
+
91
+ def validate_type(value, type, path)
92
+ return if value_matches_type?(value, type)
93
+
94
+ raise ArgumentError, "Invalid arguments: property '#{path || 'value'}' must be #{article_for(type)} #{type}"
95
+ end
96
+
97
+ def value_matches_type?(value, type)
98
+ case type
99
+ when "object"
100
+ value.is_a?(Hash)
101
+ when "array"
102
+ value.is_a?(Array)
103
+ when "string"
104
+ value.is_a?(String)
105
+ when "integer"
106
+ value.is_a?(Integer)
107
+ when "boolean"
108
+ value == true || value == false
109
+ else
110
+ true
111
+ end
112
+ end
113
+
114
+ def property_path(path, name)
115
+ path ? "#{path}.#{name}" : name
116
+ end
117
+
118
+ def article_for(type)
119
+ %w[array integer object].include?(type) ? "an" : "a"
120
+ end
121
+
122
+ def validate_default(value, path)
123
+ return unless value.is_a?(Hash) || value.is_a?(Array)
124
+
125
+ raise ArgumentError, "Invalid tool definition: property '#{path}' cannot use an object or array default"
126
+ end
127
+ end
128
+ end
@@ -1,8 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class Tidewave::Tools::ExecuteSqlQuery < Tidewave::Tools::Base
4
- tool_name "execute_sql_query"
5
- description <<~DESCRIPTION
3
+ class Tidewave::Tools::ExecuteSqlQuery < Tidewave::Tool
4
+ DESCRIPTION = <<~DESCRIPTION.freeze
6
5
  Executes the given SQL query against the database connection.
7
6
  Returns the result as a Ruby data structure.
8
7
 
@@ -17,20 +16,38 @@ class Tidewave::Tools::ExecuteSqlQuery < Tidewave::Tools::Base
17
16
  For MySQL, use ? for parameter placeholders.
18
17
  DESCRIPTION
19
18
 
20
- arguments do
21
- required(:query).filled(:string).description("The SQL query to execute. For PostgreSQL, use $1, $2 placeholders. For MySQL, use ? placeholders.")
22
- optional(:arguments).value(:array).description("The arguments to pass to the query. The query must contain corresponding parameter placeholders.")
19
+ def initialize(options = {})
20
+ @database_adapter = Tidewave::DatabaseAdapter.for(options[:orm_adapter]) if options[:orm_adapter]
23
21
  end
24
22
 
25
- def @input_schema.json_schema
26
- schema = super
27
- schema[:properties][:arguments][:items] = {}
28
- schema
23
+ def definition
24
+ return nil unless @database_adapter
25
+
26
+ {
27
+ "name" => "execute_sql_query",
28
+ "description" => DESCRIPTION,
29
+ "inputSchema" => {
30
+ "type" => "object",
31
+ "properties" => {
32
+ "query" => {
33
+ "type" => "string",
34
+ "minLength" => 1,
35
+ "description" => "The SQL query to execute. For PostgreSQL, use $1, $2 placeholders. For MySQL, use ? placeholders."
36
+ },
37
+ "arguments" => {
38
+ "type" => "array",
39
+ "items" => {},
40
+ "description" => "The arguments to pass to the query. The query must contain corresponding parameter placeholders."
41
+ }
42
+ },
43
+ "required" => [ "query" ]
44
+ }
45
+ }
29
46
  end
30
47
 
31
- RESULT_LIMIT = 50
32
-
33
- def call(query:, arguments: [])
34
- Tidewave::DatabaseAdapter.current.execute_query(query, arguments)
48
+ def call(arguments_hash)
49
+ query = arguments_hash.fetch("query")
50
+ arguments = arguments_hash.fetch("arguments", [])
51
+ @database_adapter.execute_query(query, arguments)
35
52
  end
36
53
  end
@@ -1,9 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class Tidewave::Tools::GetDocs < Tidewave::Tools::Base
4
- tool_name "get_docs"
5
-
6
- description <<~DESCRIPTION
3
+ class Tidewave::Tools::GetDocs < Tidewave::Tool
4
+ DESCRIPTION = <<~DESCRIPTION.freeze
7
5
  Returns the documentation for the given reference.
8
6
 
9
7
  The reference may be a constant, most commonly classes and modules
@@ -16,11 +14,26 @@ class Tidewave::Tools::GetDocs < Tidewave::Tools::Base
16
14
  If that is the case, prefer this tool over grepping the file system.
17
15
  DESCRIPTION
18
16
 
19
- arguments do
20
- required(:reference).filled(:string).description("The constant/method to lookup, such String, String#gsub or File.executable?")
17
+ def definition
18
+ {
19
+ "name" => "get_docs",
20
+ "description" => DESCRIPTION,
21
+ "inputSchema" => {
22
+ "type" => "object",
23
+ "properties" => {
24
+ "reference" => {
25
+ "type" => "string",
26
+ "minLength" => 1,
27
+ "description" => "The constant/method to lookup, such String, String#gsub or File.executable?"
28
+ }
29
+ },
30
+ "required" => [ "reference" ]
31
+ }
32
+ }
21
33
  end
22
34
 
23
- def call(reference:)
35
+ def call(arguments)
36
+ reference = arguments.fetch("reference")
24
37
  file_path, line_number = Tidewave::Tools::GetSourceLocation.get_source_location(reference)
25
38
 
26
39
  if file_path
@@ -47,7 +60,8 @@ class Tidewave::Tools::GetDocs < Tidewave::Tools::Base
47
60
  line = lines[current_line].chomp.strip
48
61
 
49
62
  if line.start_with?("#")
50
- comment_lines.unshift(line.sub(/^#\s|^#/, ""))
63
+ comment = line.sub(/^#\s|^#/, "")
64
+ comment_lines.unshift(comment) unless ignorable_comment?(comment)
51
65
  elsif line.empty?
52
66
  # Skip empty lines but continue looking for comments
53
67
  else
@@ -61,4 +75,8 @@ class Tidewave::Tools::GetDocs < Tidewave::Tools::Base
61
75
  return nil if comment_lines.empty?
62
76
  comment_lines.join("\n")
63
77
  end
78
+
79
+ def ignorable_comment?(comment)
80
+ comment.start_with?("rubocop:")
81
+ end
64
82
  end
@@ -1,26 +1,54 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class Tidewave::Tools::GetLogs < Tidewave::Tools::Base
4
- tool_name "get_logs"
5
- description <<~DESCRIPTION
3
+ require "pathname"
4
+
5
+ class Tidewave::Tools::GetLogs < Tidewave::Tool
6
+ DESCRIPTION = <<~DESCRIPTION.freeze
6
7
  Returns all log output, excluding logs that were caused by other tool calls.
7
8
 
8
9
  Use this tool to check for request logs or potentially logged errors.
9
10
  DESCRIPTION
10
11
 
11
- arguments do
12
- required(:tail).filled(:integer).description("The number of log entries to return from the end of the log")
13
- optional(:grep).filled(:string).description("Filter logs with the given regular expression (case insensitive). E.g. \"error\" when you want to capture errors in particular")
12
+ def initialize(options = {})
13
+ @log_file = options[:log_file] ? Pathname.new(options[:log_file].to_s) : nil
14
+ end
15
+
16
+ def definition
17
+ return nil unless @log_file
18
+
19
+ {
20
+ "name" => "get_logs",
21
+ "description" => DESCRIPTION,
22
+ "inputSchema" => {
23
+ "type" => "object",
24
+ "properties" => {
25
+ "tail" => {
26
+ "type" => "integer",
27
+ "not" => {
28
+ "type" => "null"
29
+ },
30
+ "description" => "The number of log entries to return from the end of the log"
31
+ },
32
+ "grep" => {
33
+ "type" => "string",
34
+ "minLength" => 1,
35
+ "description" => "Filter logs with the given regular expression (case insensitive). E.g. \"error\" when you want to capture errors in particular"
36
+ }
37
+ },
38
+ "required" => [ "tail" ]
39
+ }
40
+ }
14
41
  end
15
42
 
16
- def call(tail:, grep: nil)
17
- log_file = Rails.root.join("log", "#{Rails.env}.log")
18
- return "Log file not found" unless File.exist?(log_file)
43
+ def call(arguments)
44
+ tail = arguments.fetch("tail")
45
+ grep = arguments["grep"]
46
+ return "Log file not found" unless @log_file&.exist?
19
47
 
20
48
  regex = Regexp.new(grep, Regexp::IGNORECASE) if grep
21
49
  matching_lines = []
22
50
 
23
- tail_lines(log_file) do |line|
51
+ tail_lines(@log_file) do |line|
24
52
  if regex.nil? || line.match?(regex)
25
53
  matching_lines.unshift(line)
26
54
  break if matching_lines.size >= tail
@@ -1,23 +1,43 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class Tidewave::Tools::GetModels < Tidewave::Tools::Base
4
- tool_name "get_models"
5
- description <<~DESCRIPTION
3
+ require "pathname"
4
+
5
+ class Tidewave::Tools::GetModels < Tidewave::Tool
6
+ DESCRIPTION = <<~DESCRIPTION.freeze
6
7
  Returns a list of all database-backed models in the application.
7
8
  DESCRIPTION
8
9
 
9
- def call
10
- # Ensure all models are loaded
11
- Rails.application.eager_load!
10
+ def initialize(options = {})
11
+ @root = options[:root] ? Pathname.new(options[:root].to_s) : Pathname.pwd
12
+ @database_adapter = Tidewave::DatabaseAdapter.for(options[:orm_adapter]) if options[:orm_adapter]
13
+ @before_reload = options[:before_reload]
14
+ end
15
+
16
+ def definition
17
+ return nil unless @database_adapter
18
+
19
+ {
20
+ "name" => "get_models",
21
+ "description" => DESCRIPTION,
22
+ "inputSchema" => {
23
+ "type" => "object",
24
+ "properties" => {}
25
+ }
26
+ }
27
+ end
28
+
29
+ def call(_arguments)
30
+ @before_reload&.call
12
31
 
13
- # Use adapter to get models (encapsulates ORM-specific logic)
14
- models = Tidewave::DatabaseAdapter.current.get_models
32
+ models = @database_adapter.get_models
15
33
 
16
34
  models.map do |model|
35
+ display_name = model.name || model.to_s
36
+
17
37
  if location = get_relative_source_location(model.name)
18
- "* #{model.name} at #{location}"
38
+ "* #{display_name} at #{location}"
19
39
  else
20
- "* #{model.name}"
40
+ "* #{display_name}"
21
41
  end
22
42
  end.join("\n")
23
43
  end
@@ -25,16 +45,15 @@ class Tidewave::Tools::GetModels < Tidewave::Tools::Base
25
45
  private
26
46
 
27
47
  def get_relative_source_location(model_name)
48
+ return nil if model_name.nil? || model_name.empty?
49
+
28
50
  source_location = Object.const_source_location(model_name)
29
- return nil if source_location.blank?
51
+ return nil unless source_location
30
52
 
31
53
  file_path, line_number = source_location
32
- begin
33
- relative_path = Pathname.new(file_path).relative_path_from(Rails.root)
34
- "#{relative_path}:#{line_number}"
35
- rescue ArgumentError
36
- # If the path cannot be made relative, return the absolute path
37
- "#{file_path}:#{line_number}"
38
- end
54
+ relative_path = Pathname.new(file_path).relative_path_from(@root)
55
+ "#{relative_path}:#{line_number}"
56
+ rescue ArgumentError
57
+ "#{file_path}:#{line_number}"
39
58
  end
40
59
  end