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,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
@@ -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