tidewave 0.4.1 → 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,192 +0,0 @@
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
- require_relative "streamable_http_transport"
12
-
13
- class Tidewave::Middleware
14
- TIDEWAVE_ROUTE = "tidewave".freeze
15
- MCP_ROUTE = "mcp".freeze
16
- SHELL_ROUTE = "shell".freeze
17
- CONFIG_ROUTE = "config".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
- @team = config.team
29
- @project_name = Rails.application.class.module_parent.name
30
-
31
- @app = FastMcp.rack_middleware(app,
32
- name: "tidewave",
33
- version: Tidewave::VERSION,
34
- path_prefix: "/" + TIDEWAVE_ROUTE + "/" + MCP_ROUTE,
35
- transport: Tidewave::StreamableHttpTransport,
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, CONFIG_ROUTE ] ]
66
- return config_endpoint(request)
67
- when [ "POST", [ TIDEWAVE_ROUTE, SHELL_ROUTE ] ]
68
- return shell(request)
69
- end
70
- end
71
-
72
- status, headers, body = @app.call(env)
73
-
74
- # Remove X-Frame-Options headers for non-Tidewave routes to allow embedding.
75
- # CSP headers are configured in the CSP application environment.
76
- headers.delete("X-Frame-Options")
77
-
78
- [ status, headers, body ]
79
- end
80
-
81
- private
82
-
83
- def home(request)
84
- config = config_data
85
-
86
- html = <<~HTML
87
- <html>
88
- <head>
89
- <meta charset="UTF-8" />
90
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
91
- <meta name="tidewave:config" content="#{ERB::Util.html_escape(JSON.generate(config))}" />
92
- <script type="module" src="#{@client_url}/tc/tc.js"></script>
93
- </head>
94
- <body></body>
95
- </html>
96
- HTML
97
-
98
- [ 200, { "Content-Type" => "text/html" }, [ html ] ]
99
- end
100
-
101
- def config_endpoint(request)
102
- [ 200, { "Content-Type" => "application/json" }, [ JSON.generate(config_data) ] ]
103
- end
104
-
105
- def config_data
106
- {
107
- "project_name" => @project_name,
108
- "framework_type" => "rails",
109
- "tidewave_version" => Tidewave::VERSION,
110
- "team" => @team
111
- }
112
- end
113
-
114
- def forbidden(message)
115
- Rails.logger.warn(message)
116
- [ 403, { "Content-Type" => "text/plain" }, [ message ] ]
117
- end
118
-
119
- def shell(request)
120
- body = request.body.read
121
- return [ 400, { "Content-Type" => "text/plain" }, [ "Command body is required" ] ] if body.blank?
122
-
123
- begin
124
- parsed_body = JSON.parse(body)
125
- cmd = parsed_body["command"]
126
- return [ 400, { "Content-Type" => "text/plain" }, [ "Command field is required" ] ] if cmd.blank?
127
- rescue JSON::ParserError
128
- return [ 400, { "Content-Type" => "text/plain" }, [ "Invalid JSON in request body" ] ]
129
- end
130
-
131
- response = Rack::Response.new
132
- response.status = 200
133
- response.headers["Content-Type"] = "text/plain"
134
-
135
- response.finish do |res|
136
- begin
137
- Open3.popen3(*cmd) do |stdin, stdout, stderr, wait_thr|
138
- stdin.close
139
-
140
- # Merge stdout and stderr streams
141
- ios = [ stdout, stderr ]
142
-
143
- until ios.empty?
144
- ready = IO.select(ios, nil, nil, 0.1)
145
- next unless ready
146
-
147
- ready[0].each do |io|
148
- begin
149
- data = io.read_nonblock(4096)
150
- if data
151
- # Write binary chunk: type (0 for data) + 4-byte length + data
152
- chunk = [ 0, data.bytesize ].pack("CN") + data
153
- res.write(chunk)
154
- end
155
- rescue IO::WaitReadable
156
- # No data available right now
157
- rescue EOFError
158
- # Stream ended
159
- ios.delete(io)
160
- end
161
- end
162
- end
163
-
164
- # Wait for process to complete and get exit status
165
- exit_status = wait_thr.value.exitstatus
166
- status_json = JSON.generate({ status: exit_status })
167
- # Write binary chunk: type (1 for status) + 4-byte length + JSON data
168
- chunk = [ 1, status_json.bytesize ].pack("CN") + status_json
169
- res.write(chunk)
170
- end
171
- rescue => e
172
- error_json = JSON.generate({ status: 213 })
173
- chunk = [ 1, error_json.bytesize ].pack("CN") + error_json
174
- res.write(chunk)
175
- end
176
- end
177
- end
178
-
179
- def valid_client_ip?(request)
180
- return true if @allow_remote_access
181
-
182
- ip = request.ip
183
- return false unless ip
184
-
185
- addr = IPAddr.new(ip)
186
-
187
- addr.loopback? ||
188
- addr == IPAddr.new("127.0.0.1") ||
189
- addr == IPAddr.new("::1") ||
190
- addr == IPAddr.new("::ffff:127.0.0.1") # IPv4-mapped IPv6
191
- end
192
- end
@@ -1,131 +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
- [
80
- 200,
81
- { "Content-Type" => "application/json" },
82
- [ JSON.generate(@captured_response) ]
83
- ]
84
- rescue JSON::ParserError => e
85
- @logger.error("Invalid JSON in request: #{e.message}")
86
- json_rpc_error_response(400, -32700, "Parse error", nil)
87
- rescue => e
88
- @logger.error("Error processing message: #{e.message}")
89
- @logger.error(e.backtrace.join("\n")) if e.backtrace
90
- json_rpc_error_response(500, -32603, "Internal error", nil)
91
- end
92
- end
93
-
94
- def valid_jsonrpc_message?(message)
95
- return false unless message.is_a?(Hash)
96
- return false unless message["jsonrpc"] == "2.0"
97
-
98
- message.key?("method") || message.key?("result") || message.key?("error")
99
- end
100
-
101
- def method_not_allowed_response
102
- [
103
- 405,
104
- { "Content-Type" => "application/json" },
105
- [ JSON.generate({
106
- jsonrpc: "2.0",
107
- error: {
108
- code: -32601,
109
- message: "Method not allowed. This endpoint only supports POST requests."
110
- },
111
- id: nil
112
- }) ]
113
- ]
114
- end
115
-
116
- def json_rpc_error_response(http_status, code, message, id)
117
- [
118
- http_status,
119
- { "Content-Type" => "application/json" },
120
- [ JSON.generate({
121
- jsonrpc: "2.0",
122
- error: {
123
- code: code,
124
- message: message
125
- },
126
- id: id
127
- }) ]
128
- ]
129
- end
130
- end
131
- end
@@ -1,8 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Tidewave
4
- module Tools
5
- class Base < FastMcp::Tool
6
- end
7
- end
8
- end