tidewave 0.4.2 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +10 -2
- data/lib/tidewave/configuration.rb +3 -2
- data/lib/tidewave/database_adapter.rb +2 -7
- data/lib/tidewave/database_adapters/active_record.rb +2 -8
- data/lib/tidewave/database_adapters/sequel.rb +14 -2
- data/lib/tidewave/railtie.rb +14 -29
- data/lib/tidewave/tool.rb +128 -0
- data/lib/tidewave/tools/execute_sql_query.rb +31 -14
- data/lib/tidewave/tools/get_docs.rb +26 -8
- data/lib/tidewave/tools/get_logs.rb +38 -10
- data/lib/tidewave/tools/get_models.rb +37 -18
- data/lib/tidewave/tools/get_source_location.rb +50 -37
- data/lib/tidewave/tools/project_eval.rb +43 -21
- data/lib/tidewave/version.rb +2 -2
- data/lib/tidewave.rb +312 -5
- metadata +3 -33
- data/lib/tidewave/middleware.rb +0 -127
- data/lib/tidewave/streamable_http_transport.rb +0 -135
- data/lib/tidewave/tools/base.rb +0 -8
data/lib/tidewave/middleware.rb
DELETED
|
@@ -1,127 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "ipaddr"
|
|
4
|
-
require "fast_mcp"
|
|
5
|
-
require "rack/request"
|
|
6
|
-
require "active_support/core_ext/class"
|
|
7
|
-
require "active_support/core_ext/object/blank"
|
|
8
|
-
require "json"
|
|
9
|
-
require "erb"
|
|
10
|
-
require_relative "streamable_http_transport"
|
|
11
|
-
|
|
12
|
-
class Tidewave::Middleware
|
|
13
|
-
TIDEWAVE_ROUTE = "tidewave".freeze
|
|
14
|
-
MCP_ROUTE = "mcp".freeze
|
|
15
|
-
CONFIG_ROUTE = "config".freeze
|
|
16
|
-
|
|
17
|
-
INVALID_IP = <<~TEXT.freeze
|
|
18
|
-
For security reasons, Tidewave does not accept remote connections by default.
|
|
19
|
-
|
|
20
|
-
If you really want to allow remote connections, set `config.tidewave.allow_remote_access = true`.
|
|
21
|
-
TEXT
|
|
22
|
-
|
|
23
|
-
def initialize(app, config)
|
|
24
|
-
@allow_remote_access = config.allow_remote_access
|
|
25
|
-
@client_url = config.client_url
|
|
26
|
-
@team = config.team
|
|
27
|
-
@project_name = Rails.application.class.module_parent.name
|
|
28
|
-
|
|
29
|
-
@app = FastMcp.rack_middleware(app,
|
|
30
|
-
name: "tidewave",
|
|
31
|
-
version: Tidewave::VERSION,
|
|
32
|
-
path_prefix: "/" + TIDEWAVE_ROUTE + "/" + MCP_ROUTE,
|
|
33
|
-
transport: Tidewave::StreamableHttpTransport,
|
|
34
|
-
logger: config.logger || Logger.new(Rails.root.join("log", "tidewave.log")),
|
|
35
|
-
# Rails runs the HostAuthorization in dev, so we skip this
|
|
36
|
-
allowed_origins: [],
|
|
37
|
-
# We validate this one in Tidewave::Middleware
|
|
38
|
-
localhost_only: false
|
|
39
|
-
) do |server|
|
|
40
|
-
server.filter_tools do |request, tools|
|
|
41
|
-
if request.params["include_fs_tools"] != "true"
|
|
42
|
-
tools.reject { |tool| tool.tags.include?(:file_system_tool) }
|
|
43
|
-
else
|
|
44
|
-
tools
|
|
45
|
-
end
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
server.register_tools(*Tidewave::Tools::Base.descendants)
|
|
49
|
-
end
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
def call(env)
|
|
53
|
-
request = Rack::Request.new(env)
|
|
54
|
-
path = request.path.split("/").reject(&:empty?)
|
|
55
|
-
|
|
56
|
-
if path[0] == TIDEWAVE_ROUTE
|
|
57
|
-
return forbidden(INVALID_IP) unless valid_client_ip?(request)
|
|
58
|
-
|
|
59
|
-
# The MCP routes are handled downstream by FastMCP
|
|
60
|
-
case [ request.request_method, path ]
|
|
61
|
-
when [ "GET", [ TIDEWAVE_ROUTE ] ]
|
|
62
|
-
return home(request)
|
|
63
|
-
when [ "GET", [ TIDEWAVE_ROUTE, CONFIG_ROUTE ] ]
|
|
64
|
-
return config_endpoint(request)
|
|
65
|
-
end
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
status, headers, body = @app.call(env)
|
|
69
|
-
|
|
70
|
-
# Remove X-Frame-Options headers for non-Tidewave routes to allow embedding.
|
|
71
|
-
# CSP headers are configured in the CSP application environment.
|
|
72
|
-
headers.delete("X-Frame-Options")
|
|
73
|
-
|
|
74
|
-
[ status, headers, body ]
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
private
|
|
78
|
-
|
|
79
|
-
def home(request)
|
|
80
|
-
config = config_data
|
|
81
|
-
|
|
82
|
-
html = <<~HTML
|
|
83
|
-
<html>
|
|
84
|
-
<head>
|
|
85
|
-
<meta charset="UTF-8" />
|
|
86
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
87
|
-
<script type="module" src="#{@client_url}/tc/tc.js"></script>
|
|
88
|
-
</head>
|
|
89
|
-
<body></body>
|
|
90
|
-
</html>
|
|
91
|
-
HTML
|
|
92
|
-
|
|
93
|
-
[ 200, { "Content-Type" => "text/html" }, [ html ] ]
|
|
94
|
-
end
|
|
95
|
-
|
|
96
|
-
def config_endpoint(request)
|
|
97
|
-
[ 200, { "Content-Type" => "application/json" }, [ JSON.generate(config_data) ] ]
|
|
98
|
-
end
|
|
99
|
-
|
|
100
|
-
def config_data
|
|
101
|
-
{
|
|
102
|
-
"project_name" => @project_name,
|
|
103
|
-
"framework_type" => "rails",
|
|
104
|
-
"tidewave_version" => Tidewave::VERSION,
|
|
105
|
-
"team" => @team
|
|
106
|
-
}
|
|
107
|
-
end
|
|
108
|
-
|
|
109
|
-
def forbidden(message)
|
|
110
|
-
Rails.logger.warn(message)
|
|
111
|
-
[ 403, { "Content-Type" => "text/plain" }, [ message ] ]
|
|
112
|
-
end
|
|
113
|
-
|
|
114
|
-
def valid_client_ip?(request)
|
|
115
|
-
return true if @allow_remote_access
|
|
116
|
-
|
|
117
|
-
ip = request.ip
|
|
118
|
-
return false unless ip
|
|
119
|
-
|
|
120
|
-
addr = IPAddr.new(ip)
|
|
121
|
-
|
|
122
|
-
addr.loopback? ||
|
|
123
|
-
addr == IPAddr.new("127.0.0.1") ||
|
|
124
|
-
addr == IPAddr.new("::1") ||
|
|
125
|
-
addr == IPAddr.new("::ffff:127.0.0.1") # IPv4-mapped IPv6
|
|
126
|
-
end
|
|
127
|
-
end
|
|
@@ -1,135 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "json"
|
|
4
|
-
require "rack"
|
|
5
|
-
require "fast_mcp"
|
|
6
|
-
|
|
7
|
-
module Tidewave
|
|
8
|
-
# Streamable HTTP transport for MCP (POST-only, no SSE)
|
|
9
|
-
# This transport implements a simplified version of the MCP Streamable HTTP protocol
|
|
10
|
-
# that only supports POST requests for JSON-RPC messages. Unlike the full protocol,
|
|
11
|
-
# it does not support Server-Sent Events (SSE) for streaming responses.
|
|
12
|
-
class StreamableHttpTransport < FastMcp::Transports::BaseTransport
|
|
13
|
-
attr_reader :app, :path
|
|
14
|
-
|
|
15
|
-
def initialize(app, server, options = {})
|
|
16
|
-
super(server, logger: options[:logger])
|
|
17
|
-
@app = app
|
|
18
|
-
@path = options[:path_prefix] || "/mcp"
|
|
19
|
-
@running = false
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
def start
|
|
23
|
-
@logger.debug("Starting Streamable HTTP transport (POST-only) at path: #{@path}")
|
|
24
|
-
@running = true
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
def stop
|
|
28
|
-
@logger.debug("Stopping Streamable HTTP transport")
|
|
29
|
-
@running = false
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
# Send a message - capture response for synchronous HTTP
|
|
33
|
-
# Required by FastMCP::Transports::BaseTransport interface
|
|
34
|
-
def send_message(message)
|
|
35
|
-
@logger.debug("send_message called, capturing response: #{message.inspect}")
|
|
36
|
-
@captured_response = message
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
def call(env)
|
|
40
|
-
request = Rack::Request.new(env)
|
|
41
|
-
|
|
42
|
-
if request.path == @path
|
|
43
|
-
@server.transport = self
|
|
44
|
-
handle_mcp_request(request, env)
|
|
45
|
-
else
|
|
46
|
-
@app.call(env)
|
|
47
|
-
end
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
private
|
|
51
|
-
|
|
52
|
-
def handle_mcp_request(request, env)
|
|
53
|
-
if request.post?
|
|
54
|
-
handle_post_request(request)
|
|
55
|
-
else
|
|
56
|
-
method_not_allowed_response
|
|
57
|
-
end
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
def handle_post_request(request)
|
|
61
|
-
@logger.debug("Received POST request to MCP endpoint")
|
|
62
|
-
|
|
63
|
-
begin
|
|
64
|
-
body = request.body.read
|
|
65
|
-
message = JSON.parse(body)
|
|
66
|
-
|
|
67
|
-
@logger.debug("Processing message: #{message.inspect}")
|
|
68
|
-
|
|
69
|
-
unless valid_jsonrpc_message?(message)
|
|
70
|
-
return json_rpc_error_response(400, -32600, "Invalid Request", nil)
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
# Capture the response that will be sent via send_message
|
|
74
|
-
@captured_response = nil
|
|
75
|
-
@server.handle_json_request(message)
|
|
76
|
-
|
|
77
|
-
@logger.debug("Sending response: #{@captured_response.inspect}")
|
|
78
|
-
|
|
79
|
-
if @captured_response
|
|
80
|
-
[
|
|
81
|
-
200,
|
|
82
|
-
{ "Content-Type" => "application/json" },
|
|
83
|
-
[ JSON.generate(@captured_response) ]
|
|
84
|
-
]
|
|
85
|
-
else
|
|
86
|
-
[ 202, {}, [] ]
|
|
87
|
-
end
|
|
88
|
-
rescue JSON::ParserError => e
|
|
89
|
-
@logger.error("Invalid JSON in request: #{e.message}")
|
|
90
|
-
json_rpc_error_response(400, -32700, "Parse error", nil)
|
|
91
|
-
rescue => e
|
|
92
|
-
@logger.error("Error processing message: #{e.message}")
|
|
93
|
-
@logger.error(e.backtrace.join("\n")) if e.backtrace
|
|
94
|
-
json_rpc_error_response(500, -32603, "Internal error", nil)
|
|
95
|
-
end
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
def valid_jsonrpc_message?(message)
|
|
99
|
-
return false unless message.is_a?(Hash)
|
|
100
|
-
return false unless message["jsonrpc"] == "2.0"
|
|
101
|
-
|
|
102
|
-
message.key?("method") || message.key?("result") || message.key?("error")
|
|
103
|
-
end
|
|
104
|
-
|
|
105
|
-
def method_not_allowed_response
|
|
106
|
-
[
|
|
107
|
-
405,
|
|
108
|
-
{ "Content-Type" => "application/json" },
|
|
109
|
-
[ JSON.generate({
|
|
110
|
-
jsonrpc: "2.0",
|
|
111
|
-
error: {
|
|
112
|
-
code: -32601,
|
|
113
|
-
message: "Method not allowed. This endpoint only supports POST requests."
|
|
114
|
-
},
|
|
115
|
-
id: nil
|
|
116
|
-
}) ]
|
|
117
|
-
]
|
|
118
|
-
end
|
|
119
|
-
|
|
120
|
-
def json_rpc_error_response(http_status, code, message, id)
|
|
121
|
-
[
|
|
122
|
-
http_status,
|
|
123
|
-
{ "Content-Type" => "application/json" },
|
|
124
|
-
[ JSON.generate({
|
|
125
|
-
jsonrpc: "2.0",
|
|
126
|
-
error: {
|
|
127
|
-
code: code,
|
|
128
|
-
message: message
|
|
129
|
-
},
|
|
130
|
-
id: id
|
|
131
|
-
}) ]
|
|
132
|
-
]
|
|
133
|
-
end
|
|
134
|
-
end
|
|
135
|
-
end
|