tidewave 0.1.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 +7 -0
- data/README.md +61 -0
- data/Rakefile +9 -0
- data/config/database.yml +3 -0
- data/lib/tidewave/file_tracker.rb +123 -0
- data/lib/tidewave/railtie.rb +37 -0
- data/lib/tidewave/tool_resolver.rb +70 -0
- data/lib/tidewave/tools/base.rb +15 -0
- data/lib/tidewave/tools/edit_project_file.rb +48 -0
- data/lib/tidewave/tools/execute_sql_query.rb +48 -0
- data/lib/tidewave/tools/get_logs.rb +22 -0
- data/lib/tidewave/tools/get_models.rb +27 -0
- data/lib/tidewave/tools/get_source_location.rb +58 -0
- data/lib/tidewave/tools/glob_project_files.rb +18 -0
- data/lib/tidewave/tools/grep_project_files.rb +108 -0
- data/lib/tidewave/tools/list_project_files.rb +14 -0
- data/lib/tidewave/tools/package_search.rb +37 -0
- data/lib/tidewave/tools/project_eval.rb +47 -0
- data/lib/tidewave/tools/read_project_file.rb +20 -0
- data/lib/tidewave/tools/shell_eval.rb +30 -0
- data/lib/tidewave/tools/write_project_file.rb +25 -0
- data/lib/tidewave/version.rb +5 -0
- data/lib/tidewave.rb +10 -0
- metadata +124 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: ee211dd3b35cb2ea32392547b47a98b0f8e990396b2a8380b84ed276e1d31bc2
|
4
|
+
data.tar.gz: 2c93855bf42fc23fc66385b1c9c5cf0ff58279cc63a3bc15b3b85bf291529817
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 7b2d3559b9e38fc4b8f619502d41d90aa23cbdae59446f8d53d84e22be0eb41a6ae8e39223e5ccd0a37ee4a50086c2dbdd460b4dfb4ef8236677d430d6e8f3ff
|
7
|
+
data.tar.gz: a306c3e8fa81bf5b7794d5789aa62c5169eba6a60e36f1e68105d1740e9c5bbe464558c4911b04cb5a6c7560cacb532c2312d4f588192341ed458401d6e345f4
|
data/README.md
ADDED
@@ -0,0 +1,61 @@
|
|
1
|
+
# Tidewave
|
2
|
+
|
3
|
+
Tidewave speeds up development with an AI assistant that understands your web application,
|
4
|
+
how it runs, and what it delivers. Our current release connects your editor's
|
5
|
+
assistant to your web framework runtime via [MCP](https://modelcontextprotocol.io/).
|
6
|
+
|
7
|
+
[See our website](https://tidewave.ai) for more information.
|
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
|
+
## Installation
|
21
|
+
|
22
|
+
You can install Tidewave by adding the `tidewave` gem to the development group in your Gemfile:
|
23
|
+
|
24
|
+
```ruby
|
25
|
+
gem "tidewave", group: :development
|
26
|
+
```
|
27
|
+
|
28
|
+
Tidewave will now run on the same port as your regular Rails application.
|
29
|
+
In particular, the MCP is located by default at http://localhost:3000/tidewave/mcp.
|
30
|
+
[You must configure your editor and AI assistants accordingly](https://hexdocs.pm/tidewave/mcp.html).
|
31
|
+
|
32
|
+
## Considerations
|
33
|
+
|
34
|
+
### Production Environment
|
35
|
+
|
36
|
+
Tidewave is a powerful tool that can help you develop your web application faster and more efficiently.
|
37
|
+
However, it is important to note that Tidewave is not meant to be used in a production environment.
|
38
|
+
|
39
|
+
Tidewave will raise an error if it is used in a production environment.
|
40
|
+
|
41
|
+
### Web server requirements
|
42
|
+
|
43
|
+
Tidewave currently requires a threaded web server like Puma.
|
44
|
+
|
45
|
+
## Acknowledgements
|
46
|
+
|
47
|
+
A thank you to Yorick Jacquin, for creating [FastMCP](https://github.com/yjacquin/fast_mcp) and implementing the initial version of this project.
|
48
|
+
|
49
|
+
## License
|
50
|
+
|
51
|
+
Copyright (c) 2025 Dashbit
|
52
|
+
|
53
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
54
|
+
you may not use this file except in compliance with the License.
|
55
|
+
You may obtain a copy of the License at [http://www.apache.org/licenses/LICENSE-2.0](http://www.apache.org/licenses/LICENSE-2.0)
|
56
|
+
|
57
|
+
Unless required by applicable law or agreed to in writing, software
|
58
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
59
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
60
|
+
See the License for the specific language governing permissions and
|
61
|
+
limitations under the License.
|
data/Rakefile
ADDED
data/config/database.yml
ADDED
@@ -0,0 +1,123 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Tidewave
|
4
|
+
module FileTracker
|
5
|
+
extend self
|
6
|
+
|
7
|
+
def project_files
|
8
|
+
`git --git-dir #{git_root}/.git ls-files --cached --others --exclude-standard`.split("\n")
|
9
|
+
end
|
10
|
+
|
11
|
+
def read_file(path)
|
12
|
+
validate_path_access!(path)
|
13
|
+
|
14
|
+
# Retrieve the full path
|
15
|
+
full_path = file_full_path(path)
|
16
|
+
|
17
|
+
# Record the file as read
|
18
|
+
record_read(path)
|
19
|
+
|
20
|
+
# Read and return the file contents
|
21
|
+
File.read(full_path)
|
22
|
+
end
|
23
|
+
|
24
|
+
def write_file(path, content)
|
25
|
+
validate_path_access!(path, validate_existence: false)
|
26
|
+
# Retrieve the full path
|
27
|
+
full_path = file_full_path(path)
|
28
|
+
|
29
|
+
dirname = File.dirname(full_path)
|
30
|
+
|
31
|
+
# Create the directory if it doesn't exist
|
32
|
+
FileUtils.mkdir_p(dirname)
|
33
|
+
|
34
|
+
# Write the file contents
|
35
|
+
File.write(full_path, content)
|
36
|
+
|
37
|
+
# Read and return the file contents
|
38
|
+
read_file(path)
|
39
|
+
end
|
40
|
+
|
41
|
+
def file_full_path(path)
|
42
|
+
File.join(git_root, path)
|
43
|
+
end
|
44
|
+
|
45
|
+
def git_root
|
46
|
+
@git_root ||= `git rev-parse --show-toplevel`.strip
|
47
|
+
end
|
48
|
+
|
49
|
+
def validate_path_access!(path, validate_existence: true)
|
50
|
+
raise ArgumentError, "File path must not start with '..'" if path.start_with?("..")
|
51
|
+
|
52
|
+
# Ensure the path is within the project
|
53
|
+
full_path = file_full_path(path)
|
54
|
+
|
55
|
+
# Verify the file is within the project directory
|
56
|
+
raise ArgumentError, "File path must be within the project directory" unless full_path.start_with?(git_root)
|
57
|
+
|
58
|
+
# Verify the file exists
|
59
|
+
raise ArgumentError, "File not found: #{path}" unless File.exist?(full_path) && validate_existence
|
60
|
+
|
61
|
+
true
|
62
|
+
end
|
63
|
+
|
64
|
+
def validate_path_is_editable!(path)
|
65
|
+
validate_path_access!(path)
|
66
|
+
validate_path_has_been_read_since_last_write!(path)
|
67
|
+
|
68
|
+
true
|
69
|
+
end
|
70
|
+
|
71
|
+
def validate_path_is_writable!(path)
|
72
|
+
validate_path_access!(path, validate_existence: false)
|
73
|
+
validate_path_has_been_read_since_last_write!(path)
|
74
|
+
|
75
|
+
true
|
76
|
+
end
|
77
|
+
|
78
|
+
def validate_path_has_been_read_since_last_write!(path)
|
79
|
+
raise ArgumentError, "File has been modified since last read, please read the file again" unless file_was_read_since_last_write?(path)
|
80
|
+
|
81
|
+
true
|
82
|
+
end
|
83
|
+
|
84
|
+
# Record when a file was read
|
85
|
+
def record_read(path)
|
86
|
+
file_records[path] = Time.now
|
87
|
+
end
|
88
|
+
|
89
|
+
|
90
|
+
def file_was_read_since_last_write?(path)
|
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 ||= {}
|
121
|
+
end
|
122
|
+
end
|
123
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "fast_mcp"
|
4
|
+
require "logger"
|
5
|
+
require "fileutils"
|
6
|
+
require "tidewave/tool_resolver"
|
7
|
+
|
8
|
+
module Tidewave
|
9
|
+
class Railtie < Rails::Railtie
|
10
|
+
initializer "tidewave.setup_mcp" do |app|
|
11
|
+
# Prevent MCP server from being mounted if Rails is not running in development mode
|
12
|
+
raise "For security reasons, Tidewave is only supported in development mode" unless Rails.env.development?
|
13
|
+
|
14
|
+
# Set up MCP server with the host application
|
15
|
+
FastMcp.mount_in_rails(
|
16
|
+
app,
|
17
|
+
name: "tidewave",
|
18
|
+
version: Tidewave::VERSION,
|
19
|
+
path_prefix: Tidewave::PATH_PREFIX,
|
20
|
+
messages_route: Tidewave::MESSAGES_ROUTE,
|
21
|
+
sse_route: Tidewave::SSE_ROUTE,
|
22
|
+
logger: Logger.new(STDOUT)
|
23
|
+
) do |server|
|
24
|
+
app.config.before_initialize do
|
25
|
+
# Register a custom middleware to register tools depending on `include_fs_tools` query parameter
|
26
|
+
server.register_tools(*Tidewave::ToolResolver::ALL_TOOLS)
|
27
|
+
app.middleware.use Tidewave::ToolResolver, server
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
# Install generator to set up necessary files in the host application
|
33
|
+
generators do
|
34
|
+
require "generators/tidewave/install/install_generator"
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,70 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "rack"
|
4
|
+
|
5
|
+
gem_tools_path = File.expand_path("../../lib/tidewave/tools/**/*.rb", __dir__)
|
6
|
+
Dir[gem_tools_path].each { |f| require f }
|
7
|
+
|
8
|
+
module Tidewave
|
9
|
+
class ToolResolver
|
10
|
+
ALL_TOOLS = Tidewave::Tools::Base.descendants
|
11
|
+
NON_FILE_SYSTEM_TOOLS = ALL_TOOLS.reject(&:file_system_tool?)
|
12
|
+
MESSAGES_PATH = "/tidewave/messages".freeze
|
13
|
+
TOOLS_LIST_METHOD = "tools/list".freeze
|
14
|
+
INCLUDE_FS_TOOLS_PARAM = "include_fs_tools".freeze
|
15
|
+
|
16
|
+
def initialize(app, server)
|
17
|
+
@app = app
|
18
|
+
@server = server
|
19
|
+
end
|
20
|
+
|
21
|
+
def call(env)
|
22
|
+
request = Rack::Request.new(env)
|
23
|
+
request_path = request.path
|
24
|
+
request_body = extract_request_body(request)
|
25
|
+
request_params = request.params
|
26
|
+
|
27
|
+
# Override tools list response if requested
|
28
|
+
return override_tools_list_response(env) if overriding_tools_list_request?(request_path, request_params, request_body)
|
29
|
+
|
30
|
+
# Forward the request to the underlying app (RackTransport)
|
31
|
+
@app.call(env)
|
32
|
+
end
|
33
|
+
|
34
|
+
private
|
35
|
+
|
36
|
+
def extract_request_body(request)
|
37
|
+
JSON.parse(request.body.read)
|
38
|
+
rescue JSON::ParserError => e
|
39
|
+
{}
|
40
|
+
ensure
|
41
|
+
request.body.rewind
|
42
|
+
end
|
43
|
+
|
44
|
+
# When we want to exclude file system tools, we need to handle the request differently to prevent from listing them
|
45
|
+
def overriding_tools_list_request?(request_path, request_params, request_body)
|
46
|
+
request_path == MESSAGES_PATH && request_body["method"] == TOOLS_LIST_METHOD && request_params[INCLUDE_FS_TOOLS_PARAM] != "true"
|
47
|
+
end
|
48
|
+
|
49
|
+
RESPONSE_HEADERS = { "Content-Type" => "application/json" }
|
50
|
+
|
51
|
+
def override_tools_list_response(env)
|
52
|
+
register_non_file_system_tools
|
53
|
+
@app.call(env).tap { register_all_tools }
|
54
|
+
end
|
55
|
+
|
56
|
+
def register_non_file_system_tools
|
57
|
+
reset_server_tools
|
58
|
+
@server.register_tools(*NON_FILE_SYSTEM_TOOLS)
|
59
|
+
end
|
60
|
+
|
61
|
+
def register_all_tools
|
62
|
+
reset_server_tools
|
63
|
+
@server.register_tools(*ALL_TOOLS)
|
64
|
+
end
|
65
|
+
|
66
|
+
def reset_server_tools
|
67
|
+
@server.instance_variable_set(:@tools, {})
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "tidewave/file_tracker"
|
4
|
+
|
5
|
+
# TODO: This tool is not yet implemented, it does not track the latest file read.
|
6
|
+
# Also, it does not raise an error if the file contains the substring multiple times.
|
7
|
+
class Tidewave::Tools::EditProjectFile < Tidewave::Tools::Base
|
8
|
+
file_system_tool
|
9
|
+
|
10
|
+
tool_name "edit_project_file"
|
11
|
+
description <<~DESCRIPTION
|
12
|
+
A tool for editing parts of a file. It can find and replace text inside a file.
|
13
|
+
For moving or deleting files, use the shell_eval tool with 'mv' or 'rm' instead.
|
14
|
+
|
15
|
+
For large edits, use the write_project_file tool instead and overwrite the entire file.
|
16
|
+
|
17
|
+
Before editing, ensure to read the source file using the read_project_file tool.
|
18
|
+
|
19
|
+
To use this tool, provide the path to the file, the old_string to search for, and the new_string to replace it with.
|
20
|
+
If the old_string is found multiple times, an error will be returned. To ensure uniqueness, include a couple of lines
|
21
|
+
before and after the edit. All whitespace must be preserved as in the original file.
|
22
|
+
|
23
|
+
This tool can only do a single edit at a time. If you need to make multiple edits, you can create a message with
|
24
|
+
multiple tool calls to this tool, ensuring that each one contains enough context to uniquely identify the edit.
|
25
|
+
DESCRIPTION
|
26
|
+
|
27
|
+
arguments do
|
28
|
+
required(:path).filled(:string).description("The path to the file to edit. It is relative to the project root.")
|
29
|
+
required(:old_string).filled(:string).description("The string to search for")
|
30
|
+
required(:new_string).filled(:string).description("The string to replace the old_string with")
|
31
|
+
end
|
32
|
+
|
33
|
+
def call(path:, old_string:, new_string:)
|
34
|
+
# Check if the file exists within the project root and has been read
|
35
|
+
Tidewave::FileTracker.validate_path_is_editable!(path)
|
36
|
+
|
37
|
+
old_content = Tidewave::FileTracker.read_file(path)
|
38
|
+
|
39
|
+
# Ensure old_string is unique within the file
|
40
|
+
scan_result = old_content.scan(old_string)
|
41
|
+
raise ArgumentError, "old_string is not found" if scan_result.empty?
|
42
|
+
raise ArgumentError, "old_string is not unique" if scan_result.size > 1
|
43
|
+
|
44
|
+
new_content = old_content.sub(old_string, new_string)
|
45
|
+
|
46
|
+
Tidewave::FileTracker.write_file(path, new_content)
|
47
|
+
end
|
48
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Tidewave::Tools::ExecuteSqlQuery < Tidewave::Tools::Base
|
4
|
+
tool_name "execute_sql_query"
|
5
|
+
description <<~DESCRIPTION
|
6
|
+
Executes the given SQL query against the ActiveRecord database connection.
|
7
|
+
Returns the result as a Ruby data structure.
|
8
|
+
|
9
|
+
Note that the output is limited to 50 rows at a time. If you need to see more, perform additional calls
|
10
|
+
using LIMIT and OFFSET in the query. If you know that only specific columns are relevant,
|
11
|
+
only include those in the SELECT clause.
|
12
|
+
|
13
|
+
You can use this tool to select user data, manipulate entries, and introspect the application data domain.
|
14
|
+
Always ensure to use the correct SQL commands for the database you are using.
|
15
|
+
|
16
|
+
For PostgreSQL, use $1, $2, etc. for parameter placeholders.
|
17
|
+
For MySQL, use ? for parameter placeholders.
|
18
|
+
DESCRIPTION
|
19
|
+
|
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.")
|
23
|
+
end
|
24
|
+
|
25
|
+
RESULT_LIMIT = 50
|
26
|
+
|
27
|
+
def call(query:, arguments: [])
|
28
|
+
# Get the ActiveRecord connection
|
29
|
+
conn = ActiveRecord::Base.connection
|
30
|
+
|
31
|
+
# Execute the query with prepared statement and arguments
|
32
|
+
if arguments.any?
|
33
|
+
result = conn.exec_query(query, "SQL", arguments)
|
34
|
+
else
|
35
|
+
result = conn.exec_query(query)
|
36
|
+
end
|
37
|
+
|
38
|
+
|
39
|
+
# Format the result
|
40
|
+
{
|
41
|
+
columns: result.columns,
|
42
|
+
rows: result.rows.first(RESULT_LIMIT),
|
43
|
+
row_count: result.rows.length,
|
44
|
+
adapter: conn.adapter_name,
|
45
|
+
database: Rails.configuration.database_configuration.dig(Rails.env, "database")
|
46
|
+
}
|
47
|
+
end
|
48
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Tidewave::Tools::GetLogs < Tidewave::Tools::Base
|
4
|
+
tool_name "get_logs"
|
5
|
+
description <<~DESCRIPTION
|
6
|
+
Returns all log output, excluding logs that were caused by other tool calls.
|
7
|
+
|
8
|
+
Use this tool to check for request logs or potentially logged errors.
|
9
|
+
DESCRIPTION
|
10
|
+
|
11
|
+
arguments do
|
12
|
+
required(:tail).filled(:integer).description("The number of log entries to return from the end of the log")
|
13
|
+
end
|
14
|
+
|
15
|
+
def call(tail:)
|
16
|
+
log_file = Rails.root.join("log", "#{Rails.env}.log")
|
17
|
+
return "Log file not found" unless File.exist?(log_file)
|
18
|
+
|
19
|
+
logs = File.readlines(log_file).last(tail)
|
20
|
+
logs.join
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Tidewave::Tools::GetModels < Tidewave::Tools::Base
|
4
|
+
tool_name "get_models"
|
5
|
+
description <<~DESCRIPTION
|
6
|
+
Returns a list of all models in the application and their relationships.
|
7
|
+
DESCRIPTION
|
8
|
+
|
9
|
+
def call
|
10
|
+
# Ensure all models are loaded
|
11
|
+
Rails.application.eager_load!
|
12
|
+
|
13
|
+
models = ActiveRecord::Base.descendants.map do |model|
|
14
|
+
{ name: model.name, relationships: get_relationships(model) }
|
15
|
+
end
|
16
|
+
|
17
|
+
models.to_json
|
18
|
+
end
|
19
|
+
|
20
|
+
private
|
21
|
+
|
22
|
+
def get_relationships(model)
|
23
|
+
model.reflect_on_all_associations.map do |association|
|
24
|
+
{ name: association.name, type: association.macro }
|
25
|
+
end.compact_blank
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,58 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_support/core_ext/string/inflections"
|
4
|
+
require "active_support/core_ext/object/blank"
|
5
|
+
|
6
|
+
class Tidewave::Tools::GetSourceLocation < Tidewave::Tools::Base
|
7
|
+
tool_name "get_source_location"
|
8
|
+
|
9
|
+
description <<~DESCRIPTION
|
10
|
+
Returns the source location for the given module (or function).
|
11
|
+
|
12
|
+
This works for modules in the current project, as well as dependencies.
|
13
|
+
|
14
|
+
This tool only works if you know the specific module (and optionally function) that is being targeted.
|
15
|
+
If that is the case, prefer this tool over grepping the file system.
|
16
|
+
DESCRIPTION
|
17
|
+
|
18
|
+
arguments do
|
19
|
+
required(:module_name).filled(:string).description("The module to get source location for. When this is the single argument passed, the entire module source is returned.")
|
20
|
+
optional(:function_name).filled(:string).description("The function to get source location for. When used, a module must also be passed.")
|
21
|
+
end
|
22
|
+
|
23
|
+
|
24
|
+
def call(module_name:, function_name: nil)
|
25
|
+
file_path, line_number = get_source_location(module_name, function_name)
|
26
|
+
|
27
|
+
{
|
28
|
+
file_path: file_path,
|
29
|
+
line_number: line_number
|
30
|
+
}.to_json
|
31
|
+
end
|
32
|
+
|
33
|
+
private
|
34
|
+
|
35
|
+
def get_source_location(module_name, function_name)
|
36
|
+
begin
|
37
|
+
module_ref = module_name.constantize
|
38
|
+
rescue NameError
|
39
|
+
raise NameError, "Module #{module_name} not found"
|
40
|
+
end
|
41
|
+
|
42
|
+
return get_method_definition(module_ref, function_name) if function_name.present?
|
43
|
+
|
44
|
+
module_ref.const_source_location(module_name)
|
45
|
+
end
|
46
|
+
|
47
|
+
def get_method_definition(module_ref, function_name)
|
48
|
+
module_ref.method(function_name).source_location
|
49
|
+
rescue NameError
|
50
|
+
get_instance_method_definition(module_ref, function_name)
|
51
|
+
end
|
52
|
+
|
53
|
+
def get_instance_method_definition(module_ref, function_name)
|
54
|
+
module_ref.instance_method(function_name).source_location
|
55
|
+
rescue NameError
|
56
|
+
raise NameError, "Method #{function_name} not found in module #{module_ref.name}"
|
57
|
+
end
|
58
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "tidewave/file_tracker"
|
4
|
+
|
5
|
+
class Tidewave::Tools::GlobProjectFiles < Tidewave::Tools::Base
|
6
|
+
file_system_tool
|
7
|
+
|
8
|
+
tool_name "glob_project_files"
|
9
|
+
description "Searches for files matching the given glob pattern."
|
10
|
+
|
11
|
+
arguments do
|
12
|
+
required(:pattern).filled(:string).description('The glob pattern to match files against, e.g., \"**/*.ex\"')
|
13
|
+
end
|
14
|
+
|
15
|
+
def call(pattern:)
|
16
|
+
Dir.glob(pattern, base: Tidewave::FileTracker.git_root)
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,108 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Tidewave::Tools::GrepProjectFiles < Tidewave::Tools::Base
|
4
|
+
file_system_tool
|
5
|
+
|
6
|
+
def self.ripgrep_executable
|
7
|
+
@ripgrep_executable ||= `which rg`.strip
|
8
|
+
end
|
9
|
+
|
10
|
+
def self.ripgrep_available?
|
11
|
+
ripgrep_executable.present?
|
12
|
+
end
|
13
|
+
|
14
|
+
def self.description
|
15
|
+
"Searches for text patterns in files using #{ripgrep_available? ? 'ripgrep' : 'a grep variant'}."
|
16
|
+
end
|
17
|
+
tool_name "grep_project_files"
|
18
|
+
|
19
|
+
arguments do
|
20
|
+
required(:pattern).filled(:string).description("The pattern to search for")
|
21
|
+
optional(:glob).filled(:string).description(
|
22
|
+
'Optional glob pattern to filter which files to search in, e.g., \"**/*.ex\". Note that if a glob pattern is used, the .gitignore file will be ignored.'
|
23
|
+
)
|
24
|
+
optional(:case_sensitive).filled(:bool).description("Whether the search should be case-sensitive. Defaults to false.")
|
25
|
+
optional(:max_results).filled(:integer).description("Maximum number of results to return. Defaults to 100.")
|
26
|
+
end
|
27
|
+
|
28
|
+
def call(pattern:, glob: "**/*", case_sensitive: false, max_results: 100)
|
29
|
+
if self.class.ripgrep_available?
|
30
|
+
execute_ripgrep(pattern, glob, case_sensitive, max_results)
|
31
|
+
else
|
32
|
+
execute_grep(pattern, glob, case_sensitive, max_results)
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
private
|
37
|
+
|
38
|
+
def execute_ripgrep(pattern, glob, case_sensitive, max_results)
|
39
|
+
command = [ self.class.ripgrep_executable ]
|
40
|
+
command << "--no-require-git" # ignore gitignored files
|
41
|
+
command << "--json" # formatted as json
|
42
|
+
command << "--max-count=#{max_results}"
|
43
|
+
command << "--ignore-case" unless case_sensitive
|
44
|
+
command << "--glob=#{glob}" if glob.present?
|
45
|
+
command << pattern
|
46
|
+
command << "." # Search in current directory
|
47
|
+
|
48
|
+
results = `#{command.join(" ")} 2>&1`
|
49
|
+
|
50
|
+
# Process the results as needed
|
51
|
+
format_ripgrep_results(results)
|
52
|
+
end
|
53
|
+
|
54
|
+
def execute_grep(pattern, glob, case_sensitive, max_results)
|
55
|
+
files = Tidewave::Tools::GlobProjectFiles.new.call(pattern: glob)
|
56
|
+
results = []
|
57
|
+
files.each do |file|
|
58
|
+
next unless File.file?(file)
|
59
|
+
|
60
|
+
begin
|
61
|
+
file_matches = 0
|
62
|
+
line_number = 0
|
63
|
+
|
64
|
+
File.foreach(file) do |line|
|
65
|
+
line_number += 1
|
66
|
+
|
67
|
+
# Check if line matches pattern with proper case sensitivity
|
68
|
+
if case_sensitive
|
69
|
+
next unless line.include?(pattern)
|
70
|
+
else
|
71
|
+
next unless line.downcase.include?(pattern.downcase)
|
72
|
+
end
|
73
|
+
|
74
|
+
results << {
|
75
|
+
"path" => file,
|
76
|
+
"line_number" => line_number,
|
77
|
+
"content" => line.strip
|
78
|
+
}
|
79
|
+
|
80
|
+
file_matches += 1
|
81
|
+
# Stop processing this file if we've reached max results for it
|
82
|
+
break if file_matches >= max_results
|
83
|
+
end
|
84
|
+
rescue => e
|
85
|
+
# Skip files that can't be read (e.g., binary files)
|
86
|
+
next
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
results.to_json
|
91
|
+
end
|
92
|
+
|
93
|
+
def format_ripgrep_results(results)
|
94
|
+
parsed_results = results.split("\n").map(&:strip).reject(&:empty?).map do |line|
|
95
|
+
JSON.parse(line)
|
96
|
+
end
|
97
|
+
|
98
|
+
parsed_results.map do |result|
|
99
|
+
next if result["type"] != "match"
|
100
|
+
|
101
|
+
{
|
102
|
+
"path" => result.dig("data", "path", "text"),
|
103
|
+
"line_number" => result.dig("data", "line_number"),
|
104
|
+
"content" => result.dig("data", "lines", "text").strip
|
105
|
+
}
|
106
|
+
end.compact.to_json
|
107
|
+
end
|
108
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "tidewave/file_tracker"
|
4
|
+
|
5
|
+
class Tidewave::Tools::ListProjectFiles < Tidewave::Tools::Base
|
6
|
+
file_system_tool
|
7
|
+
|
8
|
+
tool_name "list_project_files"
|
9
|
+
description "Returns a list of all files in the project that are not ignored by .gitignore."
|
10
|
+
|
11
|
+
def call
|
12
|
+
Tidewave::FileTracker.project_files
|
13
|
+
end
|
14
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "faraday"
|
4
|
+
|
5
|
+
class Tidewave::Tools::PackageSearch < Tidewave::Tools::Base
|
6
|
+
tool_name "package_search"
|
7
|
+
description <<~DESCRIPTION
|
8
|
+
Searches for packages on RubyGems.
|
9
|
+
|
10
|
+
Use this tool if you need to find new packages to add to the project. Before using this tool,
|
11
|
+
get an overview of the existing dependencies by using the `project_eval` tool and executing
|
12
|
+
`Gem::Specification.map { |gem| [gem.name, gem.version] }`.
|
13
|
+
|
14
|
+
The results are paginated, with 30 packages per page. Use the `page` parameter to fetch a specific page.
|
15
|
+
DESCRIPTION
|
16
|
+
|
17
|
+
arguments do
|
18
|
+
required(:search).filled(:string).description("The search term")
|
19
|
+
optional(:page).filled(:integer, gt?: 0).description("The page number to fetch. Must be greater than 0. Defaults to 1.")
|
20
|
+
end
|
21
|
+
|
22
|
+
def call(search:, page: 1)
|
23
|
+
response = rubygems_client.get("/api/v1/search.json?query=#{search}&page=#{page}")
|
24
|
+
|
25
|
+
response.body
|
26
|
+
end
|
27
|
+
|
28
|
+
private
|
29
|
+
|
30
|
+
def rubygems_client
|
31
|
+
@rubygems_client ||= Faraday.new(url: "https://rubygems.org") do |faraday|
|
32
|
+
faraday.response :raise_error
|
33
|
+
|
34
|
+
faraday.adapter Faraday.default_adapter
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class Tidewave::Tools::ProjectEval < Tidewave::Tools::Base
|
4
|
+
tool_name "project_eval"
|
5
|
+
description <<~DESCRIPTION
|
6
|
+
Evaluates Ruby code in the context of the project.
|
7
|
+
|
8
|
+
The current Ruby version is: #{RUBY_VERSION}
|
9
|
+
|
10
|
+
The code is executed in the context of the user's project, therefore use this tool any
|
11
|
+
time you need to evaluate code, for example to test the behavior of a function or to debug
|
12
|
+
something. The tool also returns anything written to standard output.
|
13
|
+
DESCRIPTION
|
14
|
+
|
15
|
+
arguments do
|
16
|
+
required(:code).filled(:string).description("The Ruby code to evaluate")
|
17
|
+
end
|
18
|
+
|
19
|
+
def call(code:)
|
20
|
+
original_stdout = $stdout
|
21
|
+
original_stderr = $stderr
|
22
|
+
|
23
|
+
stdout_capture = StringIO.new
|
24
|
+
stderr_capture = StringIO.new
|
25
|
+
$stdout = stdout_capture
|
26
|
+
$stderr = stderr_capture
|
27
|
+
|
28
|
+
begin
|
29
|
+
result = eval(code)
|
30
|
+
stdout = stdout_capture.string
|
31
|
+
stderr = stderr_capture.string
|
32
|
+
|
33
|
+
if stdout.empty? && stderr.empty?
|
34
|
+
result
|
35
|
+
else
|
36
|
+
{
|
37
|
+
stdout: stdout,
|
38
|
+
stderr: stderr,
|
39
|
+
result: result
|
40
|
+
}
|
41
|
+
end
|
42
|
+
ensure
|
43
|
+
$stdout = original_stdout
|
44
|
+
$stderr = original_stderr
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "tidewave/file_tracker"
|
4
|
+
|
5
|
+
class Tidewave::Tools::ReadProjectFile < Tidewave::Tools::Base
|
6
|
+
file_system_tool
|
7
|
+
|
8
|
+
tool_name "read_project_file"
|
9
|
+
description <<~DESCRIPTION
|
10
|
+
Returns the contents of the given file. Matches the `resources/read` MCP method.
|
11
|
+
DESCRIPTION
|
12
|
+
|
13
|
+
arguments do
|
14
|
+
required(:path).filled(:string).description("The path to the file to read. It is relative to the project root.")
|
15
|
+
end
|
16
|
+
|
17
|
+
def call(path:)
|
18
|
+
Tidewave::FileTracker.read_file(path)
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "open3"
|
4
|
+
|
5
|
+
class Tidewave::Tools::ShellEval < Tidewave::Tools::Base
|
6
|
+
file_system_tool
|
7
|
+
class CommandFailedError < StandardError; end
|
8
|
+
|
9
|
+
tool_name "shell_eval"
|
10
|
+
description <<~DESCRIPTION
|
11
|
+
Executes a shell command in the project root directory.
|
12
|
+
|
13
|
+
Avoid using this tool for file operations. Instead, rely on dedicated file system tools, if available.
|
14
|
+
|
15
|
+
The operating system is of flavor #{RUBY_PLATFORM}.
|
16
|
+
|
17
|
+
Only use this tool if other means are not available.
|
18
|
+
DESCRIPTION
|
19
|
+
|
20
|
+
arguments do
|
21
|
+
required(:command).filled(:string).description("The shell command to execute. Avoid using this for file operations; use dedicated file system tools instead.")
|
22
|
+
end
|
23
|
+
|
24
|
+
def call(command:)
|
25
|
+
stdout, status = Open3.capture2e(command)
|
26
|
+
raise CommandFailedError, "Command failed with status #{status.exitstatus}:\n\n#{stdout}" unless status.exitstatus.zero?
|
27
|
+
|
28
|
+
stdout.strip
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "tidewave/file_tracker"
|
4
|
+
|
5
|
+
class Tidewave::Tools::WriteProjectFile < Tidewave::Tools::Base
|
6
|
+
file_system_tool
|
7
|
+
|
8
|
+
tool_name "write_project_file"
|
9
|
+
description <<~DESCRIPTION
|
10
|
+
Writes a file to the file system. If the file already exists, it will be overwritten.
|
11
|
+
|
12
|
+
Note that this tool will fail if the file wasn't previously read with the `read_project_file` tool.
|
13
|
+
DESCRIPTION
|
14
|
+
|
15
|
+
arguments do
|
16
|
+
required(:path).filled(:string).description("The path to the file to write. It is relative to the project root.")
|
17
|
+
required(:content).filled(:string).description("The content to write to the file")
|
18
|
+
end
|
19
|
+
|
20
|
+
def call(path:, content:)
|
21
|
+
Tidewave::FileTracker.validate_path_is_writable!(path)
|
22
|
+
|
23
|
+
Tidewave::FileTracker.write_file(path, content)
|
24
|
+
end
|
25
|
+
end
|
data/lib/tidewave.rb
ADDED
metadata
ADDED
@@ -0,0 +1,124 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: tidewave
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Yorick Jacquin
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2025-04-29 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: rails
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: 7.2.0
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ">="
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: 7.2.0
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: fast-mcp
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: 1.3.0
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: 1.3.0
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: faraday
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: 2.13.0
|
48
|
+
type: :runtime
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: 2.13.0
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: rack
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - ">="
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '2.0'
|
62
|
+
type: :runtime
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - ">="
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '2.0'
|
69
|
+
description: Tidewave for Rails
|
70
|
+
email:
|
71
|
+
- support@tidewave.ai
|
72
|
+
executables: []
|
73
|
+
extensions: []
|
74
|
+
extra_rdoc_files: []
|
75
|
+
files:
|
76
|
+
- README.md
|
77
|
+
- Rakefile
|
78
|
+
- config/database.yml
|
79
|
+
- lib/tidewave.rb
|
80
|
+
- lib/tidewave/file_tracker.rb
|
81
|
+
- lib/tidewave/railtie.rb
|
82
|
+
- lib/tidewave/tool_resolver.rb
|
83
|
+
- lib/tidewave/tools/base.rb
|
84
|
+
- lib/tidewave/tools/edit_project_file.rb
|
85
|
+
- lib/tidewave/tools/execute_sql_query.rb
|
86
|
+
- lib/tidewave/tools/get_logs.rb
|
87
|
+
- lib/tidewave/tools/get_models.rb
|
88
|
+
- lib/tidewave/tools/get_source_location.rb
|
89
|
+
- lib/tidewave/tools/glob_project_files.rb
|
90
|
+
- lib/tidewave/tools/grep_project_files.rb
|
91
|
+
- lib/tidewave/tools/list_project_files.rb
|
92
|
+
- lib/tidewave/tools/package_search.rb
|
93
|
+
- lib/tidewave/tools/project_eval.rb
|
94
|
+
- lib/tidewave/tools/read_project_file.rb
|
95
|
+
- lib/tidewave/tools/shell_eval.rb
|
96
|
+
- lib/tidewave/tools/write_project_file.rb
|
97
|
+
- lib/tidewave/version.rb
|
98
|
+
homepage: https://tidewave.ai/
|
99
|
+
licenses:
|
100
|
+
- Apache-2.0
|
101
|
+
metadata:
|
102
|
+
homepage_uri: https://tidewave.ai/
|
103
|
+
source_code_uri: https://github.com/tidewave-ai/tidewave_rails
|
104
|
+
changelog_uri: https://github.com/tidewave-ai/tidewave_rails/blob/main/CHANGELOG.md
|
105
|
+
post_install_message:
|
106
|
+
rdoc_options: []
|
107
|
+
require_paths:
|
108
|
+
- lib
|
109
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
110
|
+
requirements:
|
111
|
+
- - ">="
|
112
|
+
- !ruby/object:Gem::Version
|
113
|
+
version: '0'
|
114
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
115
|
+
requirements:
|
116
|
+
- - ">="
|
117
|
+
- !ruby/object:Gem::Version
|
118
|
+
version: '0'
|
119
|
+
requirements: []
|
120
|
+
rubygems_version: 3.4.1
|
121
|
+
signing_key:
|
122
|
+
specification_version: 4
|
123
|
+
summary: Tidewave for Rails
|
124
|
+
test_files: []
|