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.
@@ -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.0"
5
5
  end
data/lib/tidewave.rb CHANGED
@@ -1,13 +1,320 @@
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
+ return forbidden(INVALID_ORIGIN) if request.get_header("HTTP_ORIGIN") && path != [ TIDEWAVE_ROUTE ]
64
+
65
+ case [ request.request_method, path ]
66
+ when [ "GET", [ TIDEWAVE_ROUTE ] ]
67
+ home_endpoint(request)
68
+ when [ "GET", [ TIDEWAVE_ROUTE, CONFIG_ROUTE ] ]
69
+ config_endpoint(request)
70
+ when [ "POST", [ TIDEWAVE_ROUTE, MCP_ROUTE ] ]
71
+ mcp_endpoint(request)
72
+ else
73
+ not_found
74
+ end
75
+ else
76
+ strip_x_frame_options(@app.call(env))
77
+ end
78
+ end
79
+
80
+ private
81
+
82
+ def strip_x_frame_options(response)
83
+ status, headers, body = response
84
+ headers.delete("X-Frame-Options")
85
+ [ status, headers, body ]
86
+ end
87
+
88
+ def home_endpoint(_request)
89
+ client_url = @options[:client_url].to_s.sub(%r{/\z}, "")
90
+ body = <<~HTML
91
+ <!DOCTYPE html>
92
+ <html>
93
+ <head>
94
+ <meta charset="UTF-8" />
95
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
96
+ <script type="module" src="#{client_url}/tc/tc.js"></script>
97
+ </head>
98
+ <body></body>
99
+ </html>
100
+ HTML
101
+
102
+ [ 200, response_headers("text/html", body), [ body ] ]
103
+ end
104
+
105
+ def config_endpoint(_request)
106
+ json_response(config_data)
107
+ end
108
+
109
+ def mcp_endpoint(request)
110
+ body = request.body.read
111
+
112
+ message = JSON.parse(body)
113
+ validation_error = validate_jsonrpc_message(message)
114
+ return jsonrpc_error_response(nil, -32600, validation_error) if validation_error
115
+
116
+ response = handle_mcp_message(message)
117
+ return json_response({ "status" => "ok" }, status: 202) if response.nil?
118
+
119
+ json_response(response)
120
+ rescue JSON::ParserError
121
+ jsonrpc_error_response(nil, -32700, "Parse error")
122
+ rescue StandardError => error
123
+ @logger&.error("Error handling MCP request: #{error.message}")
124
+ jsonrpc_error_response(nil, -32603, "Internal error")
125
+ end
126
+
127
+ def config_data
128
+ {
129
+ "project_name" => @options[:project_name],
130
+ "framework_type" => @options[:framework_type],
131
+ "orm_adapter" => @options[:orm_adapter],
132
+ "team" => @options[:team] || {},
133
+ "tidewave_version" => VERSION
134
+ }
135
+ end
136
+
137
+ def json_response(payload, status: 200)
138
+ body = JSON.generate(payload)
139
+ [ status, response_headers("application/json", body), [ body ] ]
140
+ end
141
+
142
+ def forbidden(message)
143
+ @logger&.warn(message)
144
+ text_response(403, message)
145
+ end
146
+
147
+ def not_found
148
+ text_response(404, "Not Found")
149
+ end
150
+
151
+ def text_response(status, message)
152
+ [ status, response_headers("text/plain; charset=utf-8", message), [ message ] ]
153
+ end
154
+
155
+ def response_headers(content_type, body)
156
+ {
157
+ "Content-Type" => content_type,
158
+ "Content-Length" => body.bytesize.to_s
159
+ }
160
+ end
161
+
162
+ def valid_client_ip?(request)
163
+ return true if @options[:allow_remote_access]
164
+
165
+ ip = request.get_header("REMOTE_ADDR")
166
+ return false if ip.nil? || ip.empty?
167
+
168
+ address = IPAddr.new(ip)
169
+ address.loopback? || address == IPAddr.new("::ffff:127.0.0.1")
170
+ rescue IPAddr::InvalidAddressError
171
+ false
172
+ end
173
+
174
+ def validate_jsonrpc_message(message)
175
+ return "Message must be a JSON object" unless message.is_a?(Hash)
176
+ return "Invalid JSON-RPC version" unless message["jsonrpc"] == "2.0"
177
+
178
+ has_id = message.key?("id")
179
+ has_method = message.key?("method")
180
+ has_result = message.key?("result")
181
+
182
+ return nil if has_method
183
+ return nil if has_id && has_result
184
+
185
+ "Invalid JSON-RPC message structure"
186
+ end
187
+
188
+ def handle_mcp_message(message)
189
+ method = message["method"]
190
+ request_id = message["id"]
191
+ params = message["params"].is_a?(Hash) ? message["params"] : {}
192
+
193
+ case method
194
+ when "notifications/initialized", "notifications/cancelled"
195
+ nil
196
+ when "ping"
197
+ jsonrpc_success_response_body(request_id, {})
198
+ when "initialize"
199
+ handle_initialize(request_id, params)
200
+ when "tools/list"
201
+ jsonrpc_success_response_body(request_id, { "tools" => tool_definitions })
202
+ when "tools/call"
203
+ handle_tool_call(request_id, params)
204
+ else
205
+ {
206
+ "jsonrpc" => "2.0",
207
+ "id" => request_id,
208
+ "error" => {
209
+ "code" => -32601,
210
+ "message" => "Method not found",
211
+ "data" => { "name" => method }
212
+ }
213
+ }
214
+ end
215
+ end
216
+
217
+ def handle_initialize(request_id, params)
218
+ client_version = params["protocolVersion"]
219
+ return jsonrpc_error_response_body(request_id, -32602, "Protocol version is required") if client_version.nil? || client_version.empty?
220
+
221
+ if client_version < PROTOCOL_VERSION
222
+ return jsonrpc_error_response_body(
223
+ request_id,
224
+ -32602,
225
+ "Unsupported protocol version. Server supports #{PROTOCOL_VERSION} or later"
226
+ )
227
+ end
228
+
229
+ jsonrpc_success_response_body(request_id, {
230
+ "protocolVersion" => PROTOCOL_VERSION,
231
+ "capabilities" => { "tools" => { "listChanged" => false } },
232
+ "serverInfo" => {
233
+ "name" => "tidewave",
234
+ "version" => VERSION
235
+ },
236
+ "tools" => tool_definitions
237
+ })
238
+ end
239
+
240
+ def handle_tool_call(request_id, params)
241
+ tool_name = params["name"]
242
+ arguments = params["arguments"].is_a?(Hash) ? params["arguments"] : {}
243
+
244
+ return jsonrpc_error_response_body(request_id, -32602, "Tool name is required") if tool_name.nil? || tool_name.empty?
245
+
246
+ tool = @tools[tool_name]
247
+ return jsonrpc_error_response_body(request_id, -32601, "Tool '#{tool_name}' not found") if tool.nil?
248
+
249
+ result = tool.validate_and_call(arguments)
250
+ jsonrpc_success_response_body(request_id, tool_result(result))
251
+ rescue StandardError => error
252
+ @logger&.error("Tool execution error: #{error.message}")
253
+ jsonrpc_success_response_body(request_id, tool_error_result("Tool execution failed: #{error.message}"))
254
+ end
255
+
256
+ def jsonrpc_success_response_body(request_id, result)
257
+ {
258
+ "jsonrpc" => "2.0",
259
+ "id" => request_id,
260
+ "result" => result
261
+ }
262
+ end
263
+
264
+ def jsonrpc_error_response(request_id, code, message)
265
+ json_response(jsonrpc_error_response_body(request_id, code, message))
266
+ end
267
+
268
+ def jsonrpc_error_response_body(request_id, code, message)
269
+ {
270
+ "jsonrpc" => "2.0",
271
+ "id" => request_id,
272
+ "error" => {
273
+ "code" => code,
274
+ "message" => message
275
+ }
276
+ }
277
+ end
278
+
279
+ def tool_definitions
280
+ @tools.values.map(&:definition)
281
+ end
282
+
283
+ def tool_error_result(message)
284
+ {
285
+ "content" => [ text_content(message) ],
286
+ "isError" => true
287
+ }
288
+ end
289
+
290
+ def tool_result(result)
291
+ if result.is_a?(Hash)
292
+ {
293
+ "content" => [ text_content(JSON.generate(result)) ],
294
+ "structuredContent" => result
295
+ }
296
+ else
297
+ {
298
+ "content" => [ text_content(result.to_s) ]
299
+ }
300
+ end
301
+ end
302
+
303
+ def text_content(text)
304
+ {
305
+ "type" => "text",
306
+ "text" => text
307
+ }
308
+ end
309
+
310
+ def build_tool_registry
311
+ Tidewave::Tool.descendants.each_with_object({}) do |tool_class, registry|
312
+ tool = tool_class.new(@options)
313
+ definition = tool.definition
314
+ next if definition.nil?
315
+
316
+ name = definition["name"]
317
+ registry[name] = tool if name
318
+ end
12
319
  end
13
320
  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.0
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-05-29 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