tidewave 0.1.2 → 0.2.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 +4 -4
- data/README.md +25 -26
- data/lib/tidewave/configuration.rb +6 -5
- data/lib/tidewave/database_adapter.rb +33 -0
- data/lib/tidewave/database_adapters/active_record.rb +39 -0
- data/lib/tidewave/database_adapters/sequel.rb +37 -0
- data/lib/tidewave/exceptions_middleware.rb +88 -0
- data/lib/tidewave/file_tracker.rb +46 -67
- data/lib/tidewave/middleware.rb +182 -0
- data/lib/tidewave/railtie.rb +36 -27
- data/lib/tidewave/tools/base.rb +0 -7
- data/lib/tidewave/tools/edit_project_file.rb +6 -5
- data/lib/tidewave/tools/execute_sql_query.rb +2 -20
- data/lib/tidewave/tools/get_docs.rb +64 -0
- data/lib/tidewave/tools/get_models.rb +21 -10
- data/lib/tidewave/tools/get_package_location.rb +41 -0
- data/lib/tidewave/tools/get_source_location.rb +38 -35
- data/lib/tidewave/tools/list_project_files.rb +16 -4
- data/lib/tidewave/tools/project_eval.rb +49 -9
- data/lib/tidewave/tools/read_project_file.rb +9 -4
- data/lib/tidewave/tools/shell_eval.rb +1 -1
- data/lib/tidewave/tools/write_project_file.rb +7 -4
- data/lib/tidewave/version.rb +1 -1
- data/lib/tidewave.rb +6 -3
- metadata +11 -8
- data/lib/tidewave/tool_resolver.rb +0 -72
- data/lib/tidewave/tools/glob_project_files.rb +0 -18
- data/lib/tidewave/tools/grep_project_files.rb +0 -108
- data/lib/tidewave/tools/package_search.rb +0 -36
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: d708afbfcf6d89cc1456bd7bcefdc660952571cda0c6635b8c6b14b8fa31d91e
|
4
|
+
data.tar.gz: da923b2f69608fe95eb11f76795cef1598f9cc24941afe50081afc4b76c66cfa
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 9b0eaa2a77690e4dda1720e5c2c84b02ea93d9b69955e2e430745da483d30014ba3fc5d59dc40c92b0ccdedd4413f4198012813a33f2332fd7ca08a9fac79fc1
|
7
|
+
data.tar.gz: 501103bf39a69932a408ebf9e455640fc8fb97a507163d6b4f42faf27aead8811927ecc249858978c8c5a46213dbe5b1d8635ade60817f546645ec99fa4c203a
|
data/README.md
CHANGED
@@ -6,17 +6,6 @@ assistant to your web framework runtime via [MCP](https://modelcontextprotocol.i
|
|
6
6
|
|
7
7
|
[See our website](https://tidewave.ai) for more information.
|
8
8
|
|
9
|
-
## Key Features
|
10
|
-
|
11
|
-
Tidewave provides tools that allow your LLM of choice to:
|
12
|
-
|
13
|
-
- inspect your application logs to help debugging errors
|
14
|
-
- execute SQL queries and inspect your database
|
15
|
-
- evaluate custom Ruby code in the context of your project
|
16
|
-
- find Rubygems packages and source code locations
|
17
|
-
|
18
|
-
and more.
|
19
|
-
|
20
9
|
## Installation
|
21
10
|
|
22
11
|
You can install Tidewave by adding the `tidewave` gem to the development group in your Gemfile:
|
@@ -29,37 +18,47 @@ Tidewave will now run on the same port as your regular Rails application.
|
|
29
18
|
In particular, the MCP is located by default at http://localhost:3000/tidewave/mcp.
|
30
19
|
[You must configure your editor and AI assistants accordingly](https://hexdocs.pm/tidewave/mcp.html).
|
31
20
|
|
32
|
-
##
|
21
|
+
## Troubleshooting
|
33
22
|
|
34
|
-
|
23
|
+
### Localhost requirement
|
24
|
+
|
25
|
+
Tidewave expects your web application to be running on `localhost`. If you are not running on localhost, you may need to set some additional configuration. In particular, you must configure Tidewave to allow `allow_remote_access` and [optionally configure your Rails hosts](https://guides.rubyonrails.org/configuring.html#actiondispatch-hostauthorization). For example, in your `config/environments/development.rb`:
|
35
26
|
|
36
27
|
```ruby
|
37
|
-
|
28
|
+
config.hosts << "company.local"
|
29
|
+
config.tidewave.allow_remote_access = true
|
38
30
|
```
|
39
31
|
|
40
|
-
|
41
|
-
|
42
|
-
* `:allowed_origins`
|
43
|
-
|
44
|
-
* `:localhost_only`
|
45
|
-
|
46
|
-
* `:allowed_ips`
|
32
|
+
If you want to use Docker for development, you either need to enable the configuration above or automatically redirect the relevant ports, as done by [devcontainers](https://code.visualstudio.com/docs/devcontainers/containers). See our [containars](https://hexdocs.pm/tidewave/containers.html) guide for more information.
|
47
33
|
|
48
|
-
|
34
|
+
### Content security policy
|
49
35
|
|
50
|
-
|
36
|
+
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.
|
51
37
|
|
52
38
|
### Production Environment
|
53
39
|
|
54
|
-
Tidewave is a powerful tool that can help you develop your web application faster and more efficiently.
|
55
|
-
However, it is important to note that Tidewave is not meant to be used in a production environment.
|
40
|
+
Tidewave is a powerful tool that can help you develop your web application faster and more efficiently. However, it is important to note that Tidewave is not meant to be used in a production environment.
|
56
41
|
|
57
|
-
Tidewave will raise an error if it is used in
|
42
|
+
Tidewave will raise an error if it is used in any environment where code reloading is disabled (which typically includes production).
|
58
43
|
|
59
44
|
### Web server requirements
|
60
45
|
|
61
46
|
Tidewave currently requires a threaded web server like Puma.
|
62
47
|
|
48
|
+
## Configuration
|
49
|
+
|
50
|
+
You may configure `tidewave` using the following syntax:
|
51
|
+
|
52
|
+
```ruby
|
53
|
+
config.tidewave.allow_remote_access = true
|
54
|
+
```
|
55
|
+
|
56
|
+
The following options are available:
|
57
|
+
|
58
|
+
* `: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`
|
59
|
+
|
60
|
+
* `:preferred_orm` - which ORM to use, either `:active_record` (default) or `:sequel`
|
61
|
+
|
63
62
|
## Acknowledgements
|
64
63
|
|
65
64
|
A thank you to Yorick Jacquin, for creating [FastMCP](https://github.com/yjacquin/fast_mcp) and implementing the initial version of this project.
|
@@ -2,13 +2,14 @@
|
|
2
2
|
|
3
3
|
module Tidewave
|
4
4
|
class Configuration
|
5
|
-
attr_accessor :logger, :
|
5
|
+
attr_accessor :logger, :allow_remote_access, :preferred_orm, :credentials, :client_url
|
6
6
|
|
7
7
|
def initialize
|
8
|
-
@logger =
|
9
|
-
@
|
10
|
-
@
|
11
|
-
@
|
8
|
+
@logger = nil
|
9
|
+
@allow_remote_access = true
|
10
|
+
@preferred_orm = :active_record
|
11
|
+
@credentials = {}
|
12
|
+
@client_url = "https://tidewave.ai"
|
12
13
|
end
|
13
14
|
end
|
14
15
|
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Tidewave
|
4
|
+
class DatabaseAdapter
|
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
|
12
|
+
case orm_type
|
13
|
+
when :active_record
|
14
|
+
require_relative "database_adapters/active_record"
|
15
|
+
DatabaseAdapters::ActiveRecord.new
|
16
|
+
when :sequel
|
17
|
+
require_relative "database_adapters/sequel"
|
18
|
+
DatabaseAdapters::Sequel.new
|
19
|
+
else
|
20
|
+
raise "Unknown preferred ORM: #{orm_type}"
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def execute_query(query, arguments = [])
|
26
|
+
raise NotImplementedError, "Subclasses must implement execute_query"
|
27
|
+
end
|
28
|
+
|
29
|
+
def get_base_class
|
30
|
+
raise NotImplementedError, "Subclasses must implement get_base_class"
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Tidewave
|
4
|
+
module DatabaseAdapters
|
5
|
+
class ActiveRecord < DatabaseAdapter
|
6
|
+
RESULT_LIMIT = 50
|
7
|
+
|
8
|
+
def execute_query(query, arguments = [])
|
9
|
+
conn = ::ActiveRecord::Base.connection
|
10
|
+
|
11
|
+
# Execute the query with prepared statement and arguments
|
12
|
+
if arguments.any?
|
13
|
+
result = conn.exec_query(query, "SQL", arguments)
|
14
|
+
else
|
15
|
+
result = conn.exec_query(query)
|
16
|
+
end
|
17
|
+
|
18
|
+
# Format the result
|
19
|
+
{
|
20
|
+
columns: result.columns,
|
21
|
+
rows: result.rows.first(RESULT_LIMIT),
|
22
|
+
row_count: result.rows.length,
|
23
|
+
adapter: conn.adapter_name,
|
24
|
+
database: database_name
|
25
|
+
}
|
26
|
+
end
|
27
|
+
|
28
|
+
def get_base_class
|
29
|
+
::ActiveRecord::Base
|
30
|
+
end
|
31
|
+
|
32
|
+
private
|
33
|
+
|
34
|
+
def database_name
|
35
|
+
Rails.configuration.database_configuration.dig(Rails.env, "database")
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Tidewave
|
4
|
+
module DatabaseAdapters
|
5
|
+
class Sequel < DatabaseAdapter
|
6
|
+
RESULT_LIMIT = 50
|
7
|
+
|
8
|
+
def execute_query(query, arguments = [])
|
9
|
+
db = ::Sequel::Model.db
|
10
|
+
|
11
|
+
# Execute the query with arguments
|
12
|
+
result = if arguments.any?
|
13
|
+
db.fetch(query, *arguments)
|
14
|
+
else
|
15
|
+
db.fetch(query)
|
16
|
+
end
|
17
|
+
|
18
|
+
# Convert to array of hashes and extract metadata
|
19
|
+
rows = result.all
|
20
|
+
columns = rows.first&.keys || []
|
21
|
+
|
22
|
+
# Format the result similar to ActiveRecord
|
23
|
+
{
|
24
|
+
columns: columns.map(&:to_s),
|
25
|
+
rows: rows.first(RESULT_LIMIT).map(&:values),
|
26
|
+
row_count: rows.length,
|
27
|
+
adapter: db.adapter_scheme.to_s.upcase,
|
28
|
+
database: db.opts[:database]
|
29
|
+
}
|
30
|
+
end
|
31
|
+
|
32
|
+
def get_base_class
|
33
|
+
::Sequel::Model
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,88 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Tidewave::ExceptionsMiddleware
|
4
|
+
def initialize(app)
|
5
|
+
@app = app
|
6
|
+
end
|
7
|
+
|
8
|
+
def call(env)
|
9
|
+
request = ActionDispatch::Request.new(env)
|
10
|
+
status, headers, body = @app.call(env)
|
11
|
+
|
12
|
+
exception = request.get_header("tidewave.exception")
|
13
|
+
|
14
|
+
if exception
|
15
|
+
formatted = format_exception_html(exception, request)
|
16
|
+
body, headers = append_body(body, headers, formatted)
|
17
|
+
end
|
18
|
+
|
19
|
+
[ status, headers, body ]
|
20
|
+
rescue => error
|
21
|
+
Rails.logger.error("Failure in Tidewave::ExceptionsMiddleware, message: #{error.message}")
|
22
|
+
raise error
|
23
|
+
end
|
24
|
+
|
25
|
+
private
|
26
|
+
|
27
|
+
def format_exception_html(exception, request)
|
28
|
+
backtrace = Rails.backtrace_cleaner.clean(exception.backtrace)
|
29
|
+
|
30
|
+
parameters = request_parameters(request)
|
31
|
+
|
32
|
+
text = exception.class.name.dup
|
33
|
+
|
34
|
+
if parameters["controller"]
|
35
|
+
text << " in #{parameters["controller"].camelize}Controller"
|
36
|
+
|
37
|
+
if parameters["action"]
|
38
|
+
text << "##{parameters["action"]}"
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
text << "\n\n## Message\n\n#{exception.message}"
|
43
|
+
|
44
|
+
if backtrace.any?
|
45
|
+
text << "\n\n## Backtrace\n\n#{backtrace.join("\n")}"
|
46
|
+
end
|
47
|
+
|
48
|
+
text << "\n\n"
|
49
|
+
|
50
|
+
text << <<~TEXT.chomp
|
51
|
+
## Request info
|
52
|
+
|
53
|
+
* URI: #{request.base_url + request.path}
|
54
|
+
* Query string: #{request.query_string}
|
55
|
+
|
56
|
+
## Session
|
57
|
+
|
58
|
+
#{request.session.to_hash.inspect}
|
59
|
+
TEXT
|
60
|
+
|
61
|
+
%Q(<textarea style="display: none;" data-tidewave-exception-info>#{ERB::Util.html_escape(text)}</textarea>)
|
62
|
+
end
|
63
|
+
|
64
|
+
def request_parameters(request)
|
65
|
+
request.parameters
|
66
|
+
rescue ActionController::BadRequest
|
67
|
+
{}
|
68
|
+
end
|
69
|
+
|
70
|
+
def append_body(body, headers, content)
|
71
|
+
new_body = "".dup
|
72
|
+
|
73
|
+
body.each { |part| new_body << part }
|
74
|
+
body.close if body.respond_to?(:close)
|
75
|
+
|
76
|
+
if position = new_body.rindex("</body>")
|
77
|
+
new_body.insert(position, content)
|
78
|
+
else
|
79
|
+
new_body << content
|
80
|
+
end
|
81
|
+
|
82
|
+
if headers[Rack::CONTENT_LENGTH]
|
83
|
+
headers[Rack::CONTENT_LENGTH] = new_body.bytesize.to_s
|
84
|
+
end
|
85
|
+
|
86
|
+
[ [ new_body ], headers ]
|
87
|
+
end
|
88
|
+
end
|
@@ -4,120 +4,99 @@ module Tidewave
|
|
4
4
|
module FileTracker
|
5
5
|
extend self
|
6
6
|
|
7
|
-
def project_files
|
8
|
-
|
7
|
+
def project_files(glob_pattern: nil, include_ignored: false)
|
8
|
+
args = [ "ls-files", "--cached", "--others" ]
|
9
|
+
args << "--exclude-standard" unless include_ignored
|
10
|
+
args << glob_pattern if glob_pattern
|
11
|
+
`git #{args.join(" ")}`.split("\n")
|
9
12
|
end
|
10
13
|
|
11
|
-
def read_file(path)
|
12
|
-
validate_path_access!(path)
|
13
|
-
|
14
|
-
# Retrieve the full path
|
14
|
+
def read_file(path, line_offset: 0, count: nil)
|
15
15
|
full_path = file_full_path(path)
|
16
|
+
# Explicitly read the mtime first to avoid race conditions
|
17
|
+
mtime = File.mtime(full_path).to_i
|
18
|
+
content = File.read(full_path)
|
16
19
|
|
17
|
-
|
18
|
-
|
20
|
+
if line_offset > 0 || count
|
21
|
+
lines = content.lines
|
22
|
+
start_idx = [ line_offset, 0 ].max
|
23
|
+
count = (count || lines.length)
|
24
|
+
selected_lines = lines[start_idx, count]
|
25
|
+
content = selected_lines ? selected_lines.join : ""
|
26
|
+
end
|
19
27
|
|
20
|
-
|
21
|
-
File.read(full_path)
|
28
|
+
[ mtime, content ]
|
22
29
|
end
|
23
30
|
|
24
31
|
def write_file(path, content)
|
25
|
-
|
26
|
-
# Retrieve the full path
|
32
|
+
validate_ruby_syntax!(content) if ruby_file?(path)
|
27
33
|
full_path = file_full_path(path)
|
28
34
|
|
29
|
-
dirname = File.dirname(full_path)
|
30
|
-
|
31
35
|
# Create the directory if it doesn't exist
|
36
|
+
dirname = File.dirname(full_path)
|
32
37
|
FileUtils.mkdir_p(dirname)
|
33
38
|
|
34
|
-
# Write the file contents
|
39
|
+
# Write and return the file contents
|
35
40
|
File.write(full_path, content)
|
36
|
-
|
37
|
-
# Read and return the file contents
|
38
|
-
read_file(path)
|
41
|
+
content
|
39
42
|
end
|
40
43
|
|
41
44
|
def file_full_path(path)
|
42
|
-
File.
|
43
|
-
end
|
44
|
-
|
45
|
-
def git_root
|
46
|
-
@git_root ||= `git rev-parse --show-toplevel`.strip
|
45
|
+
File.expand_path(path, Rails.root)
|
47
46
|
end
|
48
47
|
|
49
48
|
def validate_path_access!(path, validate_existence: true)
|
50
|
-
raise ArgumentError, "File path must not
|
49
|
+
raise ArgumentError, "File path must not contain '..'" if path.include?("..")
|
51
50
|
|
52
51
|
# Ensure the path is within the project
|
53
52
|
full_path = file_full_path(path)
|
54
53
|
|
55
54
|
# Verify the file is within the project directory
|
56
|
-
|
55
|
+
unless full_path.start_with?(Rails.root.to_s + File::SEPARATOR)
|
56
|
+
raise ArgumentError, "File path must be within the project directory"
|
57
|
+
end
|
57
58
|
|
58
59
|
# Verify the file exists
|
59
|
-
|
60
|
+
if validate_existence && !File.exist?(full_path)
|
61
|
+
raise ArgumentError, "File not found: #{path}"
|
62
|
+
end
|
60
63
|
|
61
64
|
true
|
62
65
|
end
|
63
66
|
|
64
|
-
def validate_path_is_editable!(path)
|
67
|
+
def validate_path_is_editable!(path, atime)
|
65
68
|
validate_path_access!(path)
|
66
|
-
validate_path_has_been_read_since_last_write!(path)
|
69
|
+
validate_path_has_been_read_since_last_write!(path, atime)
|
67
70
|
|
68
71
|
true
|
69
72
|
end
|
70
73
|
|
71
|
-
def validate_path_is_writable!(path)
|
74
|
+
def validate_path_is_writable!(path, atime)
|
72
75
|
validate_path_access!(path, validate_existence: false)
|
73
|
-
validate_path_has_been_read_since_last_write!(path)
|
76
|
+
validate_path_has_been_read_since_last_write!(path, atime)
|
74
77
|
|
75
78
|
true
|
76
79
|
end
|
77
80
|
|
78
|
-
|
79
|
-
raise ArgumentError, "File has been modified since last read, please read the file again" unless file_was_read_since_last_write?(path)
|
81
|
+
private
|
80
82
|
|
81
|
-
|
83
|
+
def ruby_file?(path)
|
84
|
+
[ ".rb", ".rake", ".gemspec" ].include?(File.extname(path)) ||
|
85
|
+
[ "Gemfile" ].include?(File.basename(path))
|
82
86
|
end
|
83
87
|
|
84
|
-
|
85
|
-
|
86
|
-
|
88
|
+
def validate_ruby_syntax!(content)
|
89
|
+
RubyVM::AbstractSyntaxTree.parse(content)
|
90
|
+
rescue SyntaxError => e
|
91
|
+
raise "Invalid Ruby syntax: #{e.message}"
|
87
92
|
end
|
88
93
|
|
94
|
+
def validate_path_has_been_read_since_last_write!(path, atime)
|
95
|
+
if atime && File.mtime(file_full_path(path)).to_i > atime
|
96
|
+
raise ArgumentError, "File has been modified since last read, please read the file again"
|
97
|
+
end
|
89
98
|
|
90
|
-
|
91
|
-
file_was_read?(path) && last_read_at(path) >= last_modified_at(path)
|
92
|
-
end
|
93
|
-
|
94
|
-
# Check if a file has been read
|
95
|
-
def file_was_read?(path)
|
96
|
-
file_records.key?(path)
|
97
|
-
end
|
98
|
-
|
99
|
-
# Check if a file exists
|
100
|
-
def file_exists?(path)
|
101
|
-
File.exist?(file_full_path(path))
|
102
|
-
end
|
103
|
-
|
104
|
-
# Get the timestamp when a file was last read
|
105
|
-
def last_read_at(path)
|
106
|
-
file_records[path]
|
107
|
-
end
|
108
|
-
|
109
|
-
def last_modified_at(path)
|
110
|
-
File.mtime(file_full_path(path))
|
111
|
-
end
|
112
|
-
|
113
|
-
# Reset all tracked files (useful for testing)
|
114
|
-
def reset
|
115
|
-
@file_records = {}
|
116
|
-
end
|
117
|
-
|
118
|
-
# Hash mapping file paths to their read records
|
119
|
-
def file_records
|
120
|
-
@file_records ||= {}
|
99
|
+
true
|
121
100
|
end
|
122
101
|
end
|
123
102
|
end
|
@@ -0,0 +1,182 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "open3"
|
4
|
+
require "ipaddr"
|
5
|
+
require "fast_mcp"
|
6
|
+
require "rack/request"
|
7
|
+
require "active_support/core_ext/class"
|
8
|
+
require "active_support/core_ext/object/blank"
|
9
|
+
require "json"
|
10
|
+
require "erb"
|
11
|
+
|
12
|
+
class Tidewave::Middleware
|
13
|
+
TIDEWAVE_ROUTE = "tidewave".freeze
|
14
|
+
EMPTY_ROUTE = "empty".freeze
|
15
|
+
SSE_ROUTE = "mcp".freeze
|
16
|
+
MESSAGES_ROUTE = "mcp/message".freeze
|
17
|
+
SHELL_ROUTE = "shell".freeze
|
18
|
+
|
19
|
+
INVALID_IP = <<~TEXT.freeze
|
20
|
+
For security reasons, Tidewave does not accept remote connections by default.
|
21
|
+
|
22
|
+
If you really want to allow remote connections, set `config.tidewave.allow_remote_access = true`.
|
23
|
+
TEXT
|
24
|
+
|
25
|
+
def initialize(app, config)
|
26
|
+
@allow_remote_access = config.allow_remote_access
|
27
|
+
@client_url = config.client_url
|
28
|
+
@project_name = Rails.application.class.module_parent.name
|
29
|
+
|
30
|
+
@app = FastMcp.rack_middleware(app,
|
31
|
+
name: "tidewave",
|
32
|
+
version: Tidewave::VERSION,
|
33
|
+
path_prefix: "/" + TIDEWAVE_ROUTE,
|
34
|
+
messages_route: MESSAGES_ROUTE,
|
35
|
+
sse_route: SSE_ROUTE,
|
36
|
+
logger: config.logger || Logger.new(Rails.root.join("log", "tidewave.log")),
|
37
|
+
# Rails runs the HostAuthorization in dev, so we skip this
|
38
|
+
allowed_origins: [],
|
39
|
+
# We validate this one in Tidewave::Middleware
|
40
|
+
localhost_only: false
|
41
|
+
) do |server|
|
42
|
+
server.filter_tools do |request, tools|
|
43
|
+
if request.params["include_fs_tools"] != "true"
|
44
|
+
tools.reject { |tool| tool.tags.include?(:file_system_tool) }
|
45
|
+
else
|
46
|
+
tools
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
server.register_tools(*Tidewave::Tools::Base.descendants)
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
def call(env)
|
55
|
+
request = Rack::Request.new(env)
|
56
|
+
path = request.path.split("/").reject(&:empty?)
|
57
|
+
|
58
|
+
if path[0] == TIDEWAVE_ROUTE
|
59
|
+
return forbidden(INVALID_IP) unless valid_client_ip?(request)
|
60
|
+
|
61
|
+
# The MCP routes are handled downstream by FastMCP
|
62
|
+
case [ request.request_method, path ]
|
63
|
+
when [ "GET", [ TIDEWAVE_ROUTE ] ]
|
64
|
+
return home(request)
|
65
|
+
when [ "GET", [ TIDEWAVE_ROUTE, EMPTY_ROUTE ] ]
|
66
|
+
return empty(request)
|
67
|
+
when [ "POST", [ TIDEWAVE_ROUTE, SHELL_ROUTE ] ]
|
68
|
+
return shell(request)
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
@app.call(env)
|
73
|
+
end
|
74
|
+
|
75
|
+
private
|
76
|
+
|
77
|
+
def home(request)
|
78
|
+
config = {
|
79
|
+
"project_name" => @project_name,
|
80
|
+
"framework_type" => "rails",
|
81
|
+
"tidewave_version" => Tidewave::VERSION
|
82
|
+
}
|
83
|
+
|
84
|
+
html = <<~HTML
|
85
|
+
<html>
|
86
|
+
<head>
|
87
|
+
<meta charset="UTF-8" />
|
88
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
89
|
+
<meta name="tidewave:config" content="#{ERB::Util.html_escape(JSON.generate(config))}" />
|
90
|
+
<script type="module" src="#{@client_url}/tc/tc.js"></script>
|
91
|
+
</head>
|
92
|
+
<body></body>
|
93
|
+
</html>
|
94
|
+
HTML
|
95
|
+
|
96
|
+
[ 200, { "Content-Type" => "text/html" }, [ html ] ]
|
97
|
+
end
|
98
|
+
|
99
|
+
def empty(request)
|
100
|
+
html = ""
|
101
|
+
[ 200, { "Content-Type" => "text/html" }, [ html ] ]
|
102
|
+
end
|
103
|
+
|
104
|
+
def forbidden(message)
|
105
|
+
Rails.logger.warn(message)
|
106
|
+
[ 403, { "Content-Type" => "text/plain" }, [ message ] ]
|
107
|
+
end
|
108
|
+
|
109
|
+
def shell(request)
|
110
|
+
body = request.body.read
|
111
|
+
return [ 400, { "Content-Type" => "text/plain" }, [ "Command body is required" ] ] if body.blank?
|
112
|
+
|
113
|
+
begin
|
114
|
+
parsed_body = JSON.parse(body)
|
115
|
+
cmd = parsed_body["command"]
|
116
|
+
return [ 400, { "Content-Type" => "text/plain" }, [ "Command field is required" ] ] if cmd.blank?
|
117
|
+
rescue JSON::ParserError
|
118
|
+
return [ 400, { "Content-Type" => "text/plain" }, [ "Invalid JSON in request body" ] ]
|
119
|
+
end
|
120
|
+
|
121
|
+
response = Rack::Response.new
|
122
|
+
response.status = 200
|
123
|
+
response.headers["Content-Type"] = "text/plain"
|
124
|
+
|
125
|
+
response.finish do |res|
|
126
|
+
begin
|
127
|
+
Open3.popen3(*cmd) do |stdin, stdout, stderr, wait_thr|
|
128
|
+
stdin.close
|
129
|
+
|
130
|
+
# Merge stdout and stderr streams
|
131
|
+
ios = [ stdout, stderr ]
|
132
|
+
|
133
|
+
until ios.empty?
|
134
|
+
ready = IO.select(ios, nil, nil, 0.1)
|
135
|
+
next unless ready
|
136
|
+
|
137
|
+
ready[0].each do |io|
|
138
|
+
begin
|
139
|
+
data = io.read_nonblock(4096)
|
140
|
+
if data
|
141
|
+
# Write binary chunk: type (0 for data) + 4-byte length + data
|
142
|
+
chunk = [ 0, data.bytesize ].pack("CN") + data
|
143
|
+
res.write(chunk)
|
144
|
+
end
|
145
|
+
rescue IO::WaitReadable
|
146
|
+
# No data available right now
|
147
|
+
rescue EOFError
|
148
|
+
# Stream ended
|
149
|
+
ios.delete(io)
|
150
|
+
end
|
151
|
+
end
|
152
|
+
end
|
153
|
+
|
154
|
+
# Wait for process to complete and get exit status
|
155
|
+
exit_status = wait_thr.value.exitstatus
|
156
|
+
status_json = JSON.generate({ status: exit_status })
|
157
|
+
# Write binary chunk: type (1 for status) + 4-byte length + JSON data
|
158
|
+
chunk = [ 1, status_json.bytesize ].pack("CN") + status_json
|
159
|
+
res.write(chunk)
|
160
|
+
end
|
161
|
+
rescue => e
|
162
|
+
error_json = JSON.generate({ status: 213 })
|
163
|
+
chunk = [ 1, error_json.bytesize ].pack("CN") + error_json
|
164
|
+
res.write(chunk)
|
165
|
+
end
|
166
|
+
end
|
167
|
+
end
|
168
|
+
|
169
|
+
def valid_client_ip?(request)
|
170
|
+
return true if @allow_remote_access
|
171
|
+
|
172
|
+
ip = request.ip
|
173
|
+
return false unless ip
|
174
|
+
|
175
|
+
addr = IPAddr.new(ip)
|
176
|
+
|
177
|
+
addr.loopback? ||
|
178
|
+
addr == IPAddr.new("127.0.0.1") ||
|
179
|
+
addr == IPAddr.new("::1") ||
|
180
|
+
addr == IPAddr.new("::ffff:127.0.0.1") # IPv4-mapped IPv6
|
181
|
+
end
|
182
|
+
end
|