tidewave 0.4.2 → 0.5.1

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.
@@ -1,9 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class Tidewave::Tools::GetSourceLocation < Tidewave::Tools::Base
4
- tool_name "get_source_location"
3
+ require "pathname"
5
4
 
6
- description <<~DESCRIPTION
5
+ class Tidewave::Tools::GetSourceLocation < Tidewave::Tool
6
+ DESCRIPTION = <<~DESCRIPTION.freeze
7
7
  Returns the source location for the given reference.
8
8
 
9
9
  The reference may be a constant, most commonly classes and modules
@@ -17,55 +17,50 @@ class Tidewave::Tools::GetSourceLocation < Tidewave::Tools::Base
17
17
  You may also get the root location of a gem, you can use "dep:PACKAGE_NAME".
18
18
  DESCRIPTION
19
19
 
20
- arguments do
21
- required(:reference).filled(:string).description("The constant/method to lookup, such String, String#gsub or File.executable?")
20
+ def initialize(options = {})
21
+ @root = options[:root] ? Pathname.new(options[:root].to_s) : Pathname.pwd
22
22
  end
23
23
 
24
- def call(reference:)
25
- # Check if this is a package location request
26
- if reference.start_with?("dep:")
27
- package_name = reference.gsub("dep:", "")
28
- return get_package_location(package_name)
29
- end
30
-
31
- file_path, line_number = self.class.get_source_location(reference)
32
-
33
- if file_path
34
- begin
35
- relative_path = Pathname.new(file_path).relative_path_from(Rails.root)
36
- "#{relative_path}:#{line_number}"
37
- rescue ArgumentError
38
- # If the path cannot be made relative, return the absolute path
39
- "#{file_path}:#{line_number}"
40
- end
41
- else
42
- raise NameError, "could not find source location for #{reference}"
43
- end
24
+ def definition
25
+ {
26
+ "name" => "get_source_location",
27
+ "description" => DESCRIPTION,
28
+ "inputSchema" => {
29
+ "type" => "object",
30
+ "properties" => {
31
+ "reference" => {
32
+ "type" => "string",
33
+ "description" => "The constant/method to lookup, such String, String#gsub or File.executable?",
34
+ "minLength" => 1
35
+ }
36
+ },
37
+ "required" => [ "reference" ]
38
+ }
39
+ }
44
40
  end
45
41
 
46
- def get_package_location(package)
47
- raise "dep: prefix only works with projects using Bundler" unless defined?(Bundler)
48
- specs = Bundler.load.specs
42
+ def call(arguments)
43
+ reference = arguments.fetch("reference")
49
44
 
50
- spec = specs.find { |s| s.name == package }
51
- if spec
52
- spec.full_gem_path
45
+ if reference.start_with?("dep:")
46
+ get_package_location(reference.delete_prefix("dep:"))
53
47
  else
54
- raise "Package #{package} not found. Check your Gemfile for available packages."
48
+ file_path, line_number = self.class.get_source_location(reference)
49
+ raise NameError, "could not find source location for #{reference}" unless file_path
50
+
51
+ format_source_location(file_path, line_number)
55
52
  end
56
53
  end
57
54
 
58
55
  def self.get_source_location(reference)
59
56
  constant_path, selector, method_name = reference.rpartition(/\.|#/)
60
-
61
- # There are no selectors, so the method_name is a constant path
62
57
  return Object.const_source_location(method_name) if selector.empty?
63
58
 
64
59
  begin
65
60
  mod = Object.const_get(constant_path)
66
- rescue NameError => e
67
- raise e
68
- rescue
61
+ rescue NameError => error
62
+ raise error
63
+ rescue StandardError
69
64
  raise "wrong or invalid reference #{reference}"
70
65
  end
71
66
 
@@ -77,4 +72,22 @@ class Tidewave::Tools::GetSourceLocation < Tidewave::Tools::Base
77
72
  mod.method(method_name).source_location
78
73
  end
79
74
  end
75
+
76
+ private
77
+
78
+ def format_source_location(file_path, line_number)
79
+ relative_path = Pathname.new(file_path).relative_path_from(@root)
80
+ "#{relative_path}:#{line_number}"
81
+ rescue ArgumentError
82
+ "#{file_path}:#{line_number}"
83
+ end
84
+
85
+ def get_package_location(package)
86
+ raise "dep: prefix only works with projects using Bundler" unless defined?(Bundler)
87
+
88
+ spec = Bundler.load.specs.find { |loaded_spec| loaded_spec.name == package }
89
+ return spec.full_gem_path if spec
90
+
91
+ raise "Package #{package} not found. Check your Gemfile for available packages."
92
+ end
80
93
  end
@@ -1,11 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "timeout"
4
3
  require "json"
4
+ require "stringio"
5
+ require "timeout"
5
6
 
6
- class Tidewave::Tools::ProjectEval < Tidewave::Tools::Base
7
- tool_name "project_eval"
8
- description <<~DESCRIPTION
7
+ class Tidewave::Tools::ProjectEval < Tidewave::Tool
8
+ DESCRIPTION = <<~DESCRIPTION.freeze
9
9
  Evaluates Ruby code in the context of the project.
10
10
 
11
11
  The current Ruby version is: #{RUBY_VERSION}
@@ -16,20 +16,44 @@ class Tidewave::Tools::ProjectEval < Tidewave::Tools::Base
16
16
  output. DO NOT use shell tools to evaluate Ruby code.
17
17
  DESCRIPTION
18
18
 
19
- arguments do
20
- required(:code).filled(:string).description("The Ruby code to evaluate")
21
- optional(:arguments).value(:array).description("The arguments to pass to evaluation. They are available inside the evaluated code as `arguments`.")
22
- optional(:timeout).filled(:integer).description("The timeout in milliseconds. If the evaluation takes longer than this, it will be terminated. Defaults to 30000 (30 seconds).")
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.")
19
+ DEFAULT_TIMEOUT = 30_000
20
+
21
+ def definition
22
+ {
23
+ "name" => "project_eval",
24
+ "description" => DESCRIPTION,
25
+ "inputSchema" => {
26
+ "type" => "object",
27
+ "properties" => {
28
+ "arguments" => {
29
+ "description" => "The arguments to pass to evaluation. They are available inside the evaluated code as `arguments`.",
30
+ "items" => {},
31
+ "type" => "array"
32
+ },
33
+ "code" => {
34
+ "description" => "The Ruby code to evaluate",
35
+ "type" => "string",
36
+ "minLength" => 1
37
+ },
38
+ "timeout" => {
39
+ "description" => "The timeout in milliseconds. If the evaluation takes longer than this, it will be terminated. Defaults to 30000 (30 seconds).",
40
+ "type" => "integer",
41
+ "not" => {
42
+ "type" => "null"
43
+ }
44
+ }
45
+ },
46
+ "required" => [ "code" ]
47
+ }
48
+ }
24
49
  end
25
50
 
26
- def @input_schema.json_schema
27
- schema = super
28
- schema[:properties][:arguments][:items] = {}
29
- schema
30
- end
51
+ def call(arguments_hash)
52
+ code = arguments_hash.fetch("code")
53
+ arguments = arguments_hash.fetch("arguments", [])
54
+ timeout = arguments_hash.fetch("timeout", DEFAULT_TIMEOUT)
55
+ json = arguments_hash.fetch("json", false)
31
56
 
32
- def call(code:, arguments: [], timeout: 30_000, json: false)
33
57
  original_stdout = $stdout
34
58
  original_stderr = $stderr
35
59
 
@@ -56,14 +80,12 @@ class Tidewave::Tools::ProjectEval < Tidewave::Tools::Base
56
80
 
57
81
  if json
58
82
  JSON.generate({
59
- result: result,
60
- success: success,
61
- stdout: stdout,
62
- stderr: stderr
83
+ "result" => result,
84
+ "success" => success,
85
+ "stdout" => stdout,
86
+ "stderr" => stderr
63
87
  })
64
88
  elsif stdout.empty? && stderr.empty?
65
- # We explicitly call to_s so the result is not accidentally
66
- # parsed as a JSON response by FastMCP.
67
89
  result.to_s
68
90
  else
69
91
  <<~OUTPUT
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Tidewave
4
- VERSION = "0.4.2"
3
+ class Tidewave
4
+ VERSION = "0.5.1"
5
5
  end
data/lib/tidewave.rb CHANGED
@@ -1,13 +1,335 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "ipaddr"
4
+ require "json"
5
+ require "rack/request"
3
6
  require "tidewave/version"
4
- require "tidewave/railtie"
7
+ require "tidewave/tool"
5
8
  require "tidewave/database_adapter"
9
+ require "tidewave/railtie" if defined?(Rails::Railtie)
6
10
 
7
- # Ensure DatabaseAdapters module is available
8
- module Tidewave
11
+ class Tidewave
9
12
  module DatabaseAdapters
10
- # This module is defined here to ensure it's available for autoloading
11
- # Individual adapters are loaded on-demand in database_adapter.rb
13
+ # This module is defined here to ensure it's available for autoloading.
14
+ # Individual adapters are loaded on-demand in database_adapter.rb.
15
+ end
16
+
17
+ module Tools
18
+ # This module is defined here to ensure it's available for autoloading.
19
+ end
20
+ end
21
+
22
+ gem_tools_path = File.expand_path("tidewave/tools/**/*.rb", __dir__)
23
+ Dir[gem_tools_path].sort.each do |file|
24
+ require file
25
+ end
26
+
27
+ class Tidewave
28
+ TIDEWAVE_ROUTE = "tidewave".freeze
29
+ MCP_ROUTE = "mcp".freeze
30
+ CONFIG_ROUTE = "config".freeze
31
+ PROTOCOL_VERSION = "2025-03-26".freeze
32
+
33
+ INVALID_IP = <<~TEXT.freeze
34
+ For security reasons, Tidewave does not accept remote connections by default.
35
+
36
+ If you really want to allow remote connections, configure Tidewave with the `allow_remote_access: true` option
37
+ TEXT
38
+
39
+ INVALID_ORIGIN = "For security reasons, Tidewave does not accept requests with an origin header for this endpoint.".freeze
40
+
41
+ DEFAULT_OPTIONS = {
42
+ allow_remote_access: false,
43
+ client_url: "https://tidewave.ai",
44
+ framework_type: "rack",
45
+ team: {}
46
+ }.freeze
47
+
48
+ def initialize(app, options = {})
49
+ @app = app
50
+ @options = DEFAULT_OPTIONS.merge(options || {})
51
+ raise ArgumentError, "project_name is required" if @options[:project_name].to_s.empty?
52
+
53
+ @logger = @options[:logger]
54
+ @tools = build_tool_registry
55
+ end
56
+
57
+ def call(env)
58
+ request = Rack::Request.new(env)
59
+ path = request.path.split("/").reject(&:empty?)
60
+
61
+ if path[0] == TIDEWAVE_ROUTE
62
+ 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
66
+
67
+ case [ request.request_method, path ]
68
+ when [ "GET", [ TIDEWAVE_ROUTE ] ]
69
+ home_endpoint(request)
70
+ when [ "GET", [ TIDEWAVE_ROUTE, CONFIG_ROUTE ] ]
71
+ config_endpoint(request)
72
+ when [ "POST", [ TIDEWAVE_ROUTE, MCP_ROUTE ] ]
73
+ mcp_endpoint(request)
74
+ else
75
+ not_found
76
+ end
77
+ else
78
+ strip_x_frame_options(@app.call(env))
79
+ end
80
+ end
81
+
82
+ private
83
+
84
+ def strip_x_frame_options(response)
85
+ status, headers, body = response
86
+ headers.delete("X-Frame-Options")
87
+ [ status, headers, body ]
88
+ end
89
+
90
+ def home_endpoint(_request)
91
+ client_url = @options[:client_url].to_s.sub(%r{/\z}, "")
92
+ body = <<~HTML
93
+ <!DOCTYPE html>
94
+ <html>
95
+ <head>
96
+ <meta charset="UTF-8" />
97
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
98
+ <script type="module" src="#{client_url}/tc/tc.js"></script>
99
+ </head>
100
+ <body></body>
101
+ </html>
102
+ HTML
103
+
104
+ [ 200, response_headers("text/html", body), [ body ] ]
105
+ end
106
+
107
+ def config_endpoint(request)
108
+ json_response(config_data(request), headers: { "Access-Control-Allow-Origin" => "*" })
109
+ end
110
+
111
+ def mcp_endpoint(request)
112
+ body = request.body.read
113
+
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)
122
+ rescue JSON::ParserError
123
+ jsonrpc_error_response(nil, -32700, "Parse error")
124
+ rescue StandardError => error
125
+ @logger&.error("Error handling MCP request: #{error.message}")
126
+ jsonrpc_error_response(nil, -32603, "Internal error")
127
+ end
128
+
129
+ def config_data(request)
130
+ {
131
+ "project_name" => @options[:project_name],
132
+ "framework_type" => @options[:framework_type],
133
+ "orm_adapter" => @options[:orm_adapter],
134
+ "team" => @options[:team] || {},
135
+ "tidewave_version" => VERSION,
136
+ "local_port" => local_port(request)
137
+ }
138
+ end
139
+
140
+ def json_response(payload, status: 200, headers: {})
141
+ body = JSON.generate(payload)
142
+ [ status, response_headers("application/json", body).merge(headers), [ body ] ]
143
+ end
144
+
145
+ def forbidden(message)
146
+ @logger&.warn(message)
147
+ text_response(403, message)
148
+ end
149
+
150
+ def not_found
151
+ text_response(404, "Not Found")
152
+ end
153
+
154
+ def text_response(status, message)
155
+ [ status, response_headers("text/plain; charset=utf-8", message), [ message ] ]
156
+ end
157
+
158
+ def response_headers(content_type, body)
159
+ {
160
+ "Content-Type" => content_type,
161
+ "Content-Length" => body.bytesize.to_s
162
+ }
163
+ end
164
+
165
+ def origin_allowed_path?(path)
166
+ path == [ TIDEWAVE_ROUTE ] || path == [ TIDEWAVE_ROUTE, CONFIG_ROUTE ]
167
+ end
168
+
169
+ def local_port(request)
170
+ sock = request.env["puma.socket"]
171
+ return unless sock
172
+
173
+ addr = sock.respond_to?(:local_address) ? sock.local_address : sock.to_io.local_address
174
+ addr.ip? ? addr.ip_port : nil
175
+ end
176
+
177
+ def valid_client_ip?(request)
178
+ return true if @options[:allow_remote_access]
179
+
180
+ ip = request.get_header("REMOTE_ADDR")
181
+ return false if ip.nil? || ip.empty?
182
+
183
+ address = IPAddr.new(ip)
184
+ address.loopback? || address == IPAddr.new("::ffff:127.0.0.1")
185
+ rescue IPAddr::InvalidAddressError
186
+ false
187
+ end
188
+
189
+ def validate_jsonrpc_message(message)
190
+ return "Message must be a JSON object" unless message.is_a?(Hash)
191
+ return "Invalid JSON-RPC version" unless message["jsonrpc"] == "2.0"
192
+
193
+ has_id = message.key?("id")
194
+ has_method = message.key?("method")
195
+ has_result = message.key?("result")
196
+
197
+ return nil if has_method
198
+ return nil if has_id && has_result
199
+
200
+ "Invalid JSON-RPC message structure"
201
+ end
202
+
203
+ def handle_mcp_message(message)
204
+ method = message["method"]
205
+ request_id = message["id"]
206
+ params = message["params"].is_a?(Hash) ? message["params"] : {}
207
+
208
+ case method
209
+ when "notifications/initialized", "notifications/cancelled"
210
+ nil
211
+ when "ping"
212
+ jsonrpc_success_response_body(request_id, {})
213
+ when "initialize"
214
+ handle_initialize(request_id, params)
215
+ when "tools/list"
216
+ jsonrpc_success_response_body(request_id, { "tools" => tool_definitions })
217
+ when "tools/call"
218
+ handle_tool_call(request_id, params)
219
+ else
220
+ {
221
+ "jsonrpc" => "2.0",
222
+ "id" => request_id,
223
+ "error" => {
224
+ "code" => -32601,
225
+ "message" => "Method not found",
226
+ "data" => { "name" => method }
227
+ }
228
+ }
229
+ end
230
+ end
231
+
232
+ def handle_initialize(request_id, params)
233
+ client_version = params["protocolVersion"]
234
+ return jsonrpc_error_response_body(request_id, -32602, "Protocol version is required") if client_version.nil? || client_version.empty?
235
+
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
+
244
+ jsonrpc_success_response_body(request_id, {
245
+ "protocolVersion" => PROTOCOL_VERSION,
246
+ "capabilities" => { "tools" => { "listChanged" => false } },
247
+ "serverInfo" => {
248
+ "name" => "tidewave",
249
+ "version" => VERSION
250
+ },
251
+ "tools" => tool_definitions
252
+ })
253
+ end
254
+
255
+ def handle_tool_call(request_id, params)
256
+ tool_name = params["name"]
257
+ arguments = params["arguments"].is_a?(Hash) ? params["arguments"] : {}
258
+
259
+ return jsonrpc_error_response_body(request_id, -32602, "Tool name is required") if tool_name.nil? || tool_name.empty?
260
+
261
+ tool = @tools[tool_name]
262
+ return jsonrpc_error_response_body(request_id, -32601, "Tool '#{tool_name}' not found") if tool.nil?
263
+
264
+ result = tool.validate_and_call(arguments)
265
+ jsonrpc_success_response_body(request_id, tool_result(result))
266
+ rescue StandardError => error
267
+ @logger&.error("Tool execution error: #{error.message}")
268
+ jsonrpc_success_response_body(request_id, tool_error_result("Tool execution failed: #{error.message}"))
269
+ end
270
+
271
+ def jsonrpc_success_response_body(request_id, result)
272
+ {
273
+ "jsonrpc" => "2.0",
274
+ "id" => request_id,
275
+ "result" => result
276
+ }
277
+ end
278
+
279
+ def jsonrpc_error_response(request_id, code, message)
280
+ json_response(jsonrpc_error_response_body(request_id, code, message))
281
+ end
282
+
283
+ def jsonrpc_error_response_body(request_id, code, message)
284
+ {
285
+ "jsonrpc" => "2.0",
286
+ "id" => request_id,
287
+ "error" => {
288
+ "code" => code,
289
+ "message" => message
290
+ }
291
+ }
292
+ end
293
+
294
+ def tool_definitions
295
+ @tools.values.map(&:definition)
296
+ end
297
+
298
+ def tool_error_result(message)
299
+ {
300
+ "content" => [ text_content(message) ],
301
+ "isError" => true
302
+ }
303
+ end
304
+
305
+ def tool_result(result)
306
+ if result.is_a?(Hash)
307
+ {
308
+ "content" => [ text_content(JSON.generate(result)) ],
309
+ "structuredContent" => result
310
+ }
311
+ else
312
+ {
313
+ "content" => [ text_content(result.to_s) ]
314
+ }
315
+ end
316
+ end
317
+
318
+ def text_content(text)
319
+ {
320
+ "type" => "text",
321
+ "text" => text
322
+ }
323
+ end
324
+
325
+ def build_tool_registry
326
+ Tidewave::Tool.descendants.each_with_object({}) do |tool_class, registry|
327
+ tool = tool_class.new(@options)
328
+ definition = tool.definition
329
+ next if definition.nil?
330
+
331
+ name = definition["name"]
332
+ registry[name] = tool if name
333
+ end
12
334
  end
13
335
  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.4.2
4
+ version: 0.5.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yorick Jacquin
@@ -9,36 +9,8 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2026-02-05 00:00:00.000000000 Z
12
+ date: 2026-06-14 00:00:00.000000000 Z
13
13
  dependencies:
14
- - !ruby/object:Gem::Dependency
15
- name: rails
16
- requirement: !ruby/object:Gem::Requirement
17
- requirements:
18
- - - ">="
19
- - !ruby/object:Gem::Version
20
- version: 7.1.0
21
- type: :runtime
22
- prerelease: false
23
- version_requirements: !ruby/object:Gem::Requirement
24
- requirements:
25
- - - ">="
26
- - !ruby/object:Gem::Version
27
- version: 7.1.0
28
- - !ruby/object:Gem::Dependency
29
- name: fast-mcp
30
- requirement: !ruby/object:Gem::Requirement
31
- requirements:
32
- - - "~>"
33
- - !ruby/object:Gem::Version
34
- version: 1.6.0
35
- type: :runtime
36
- prerelease: false
37
- version_requirements: !ruby/object:Gem::Requirement
38
- requirements:
39
- - - "~>"
40
- - !ruby/object:Gem::Version
41
- version: 1.6.0
42
14
  - !ruby/object:Gem::Dependency
43
15
  name: rack
44
16
  requirement: !ruby/object:Gem::Requirement
@@ -69,11 +41,9 @@ files:
69
41
  - lib/tidewave/database_adapters/active_record.rb
70
42
  - lib/tidewave/database_adapters/sequel.rb
71
43
  - lib/tidewave/exceptions_middleware.rb
72
- - lib/tidewave/middleware.rb
73
44
  - lib/tidewave/quiet_requests_middleware.rb
74
45
  - lib/tidewave/railtie.rb
75
- - lib/tidewave/streamable_http_transport.rb
76
- - lib/tidewave/tools/base.rb
46
+ - lib/tidewave/tool.rb
77
47
  - lib/tidewave/tools/execute_sql_query.rb
78
48
  - lib/tidewave/tools/get_docs.rb
79
49
  - lib/tidewave/tools/get_logs.rb