tidewave 0.5.1 → 0.8.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 +105 -38
- data/lib/tidewave/configuration.rb +2 -1
- data/lib/tidewave/magic_bytes.rb +20 -0
- data/lib/tidewave/railtie.rb +14 -2
- data/lib/tidewave/tools/execute_sql_query.rb +12 -1
- data/lib/tidewave/version.rb +1 -1
- data/lib/tidewave.rb +302 -35
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d8aec0fffb76b0c91651452a2d8e2b1adc3fe37681a0c778c01a4762602985ed
|
|
4
|
+
data.tar.gz: d0f6abae17bcc8281baed3f3db784b0243186c5008a8866a612e42bda4333e14
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8b683cb4a188e66e456078a39467a10ff6bc9aa1ac3b3f2e92338eadf5cbe1b0a693e6d80dd32d6e8dfdb167dd0b83af1d1183c442fc6d8348beb036180e05ba
|
|
7
|
+
data.tar.gz: b3366af6bdd77c9312e8e6d70e56d898b02f75555f9b1932fea379bdb36621a8381f31df8696f86f7a6a5604885ae0ded2f22ee51a9bf323e80c7c7e52422c99
|
data/README.md
CHANGED
|
@@ -1,27 +1,124 @@
|
|
|
1
|
-
# Tidewave
|
|
1
|
+
# Tidewave Rails
|
|
2
2
|
|
|
3
|
-
Tidewave is
|
|
3
|
+
Tidewave Rails is an MCP server that provides runtime-level tools for developing Ruby on Rails apps using coding agents.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Your agent will be able to use this MCP server to talk to your running Rails app in development to:
|
|
6
|
+
|
|
7
|
+
- execute code in the context of the running app (like a Rails console for agents)
|
|
8
|
+
- read the app's live logs
|
|
9
|
+
- query your development database
|
|
10
|
+
- get source locations of classes and methods
|
|
11
|
+
- read documentation pinned to the exact gem versions your app depends on
|
|
12
|
+
|
|
13
|
+
This MCP server is an open-source component of [Tidewave](https://tidewave.ai), the agentic development environment for Rails and Phoenix.
|
|
14
|
+
|
|
15
|
+
You can use this project as a standalone MCP server or integrated with the [Tidewave product](https://tidewave.ai) by following the instructions below.
|
|
6
16
|
|
|
7
17
|
## Installation
|
|
8
18
|
|
|
9
|
-
|
|
19
|
+
### 1. Add the Tidewave gem to your app
|
|
20
|
+
|
|
21
|
+
You can add Tidewave Rails to your app by running:
|
|
10
22
|
|
|
11
23
|
```shell
|
|
12
24
|
bundle add tidewave --group development
|
|
13
25
|
```
|
|
14
26
|
|
|
15
|
-
or by
|
|
27
|
+
or by manually adding the `tidewave` gem to the development group in your Gemfile:
|
|
16
28
|
|
|
17
29
|
```ruby
|
|
18
30
|
gem "tidewave", group: :development
|
|
19
31
|
```
|
|
20
32
|
|
|
21
|
-
|
|
33
|
+
### 2. Add the Tidewave MCP to your agent/editor
|
|
34
|
+
|
|
35
|
+
Add the Tidewave MCP server to your editor or MCP client configuration as the type "http" (streamable), pointing to the `/tidewave/mcp` path and port your web application is running at. For example, `http://localhost:3000/tidewave/mcp`.
|
|
36
|
+
|
|
37
|
+
We also have specific instructions for:
|
|
38
|
+
|
|
39
|
+
- [Claude Code](https://tidewave.hexdocs.pm/mcp_claude_code.html)
|
|
40
|
+
- [Codex](https://tidewave.hexdocs.pm/mcp_codex.html)
|
|
41
|
+
- [Cursor](https://tidewave.hexdocs.pm/mcp_cursor.html)
|
|
42
|
+
- [Neovim](https://tidewave.hexdocs.pm/mcp_neovim.html)
|
|
43
|
+
- [OpenCode](https://tidewave.hexdocs.pm/mcp_opencode.html)
|
|
44
|
+
- [VS Code](https://tidewave.hexdocs.pm/mcp_vscode.html)
|
|
45
|
+
- [Zed](https://tidewave.hexdocs.pm/mcp_zed.html)
|
|
46
|
+
- [Others](https://tidewave.hexdocs.pm/mcp.html)
|
|
47
|
+
|
|
48
|
+
## Usage
|
|
49
|
+
|
|
50
|
+
As with any other MCP server, your agent will call the tools exposed by the Tidewave MCP whenever it sees fit. But you can also prompt it to call them explicitly.
|
|
51
|
+
|
|
52
|
+
## Available MCP tools
|
|
53
|
+
|
|
54
|
+
### `project_eval`
|
|
55
|
+
|
|
56
|
+
Evaluates Ruby code in the context of your running application, with access to its runtime, loaded dependencies, and in-memory data, returning the result plus anything printed to standard output. It's like a Rails console for the agent.
|
|
57
|
+
|
|
58
|
+
[](https://asciinema.org/a/v0Bs9WtOUARzewC3)
|
|
59
|
+
|
|
60
|
+
Your agent can use it when it would rather run code than assume behavior, grounding its next step in what the running app actually does. For example, calling a method to see what comes back or reproducing a failing code path against live app state to debug it.
|
|
61
|
+
|
|
62
|
+
### `execute_sql_query`
|
|
63
|
+
|
|
64
|
+
Runs a SQL query against your app's development database and returns the rows to the agent.
|
|
65
|
+
|
|
66
|
+
[](https://asciinema.org/a/qJtkEDf2YAPqjBHI)
|
|
67
|
+
|
|
68
|
+
Your agent can use it to run any SQL against your development database. For example, ask it to insert some test records to see how a page looks with realistic data. Or, after a create action, the agent can verify whether the record was saved with the expected values.
|
|
69
|
+
|
|
70
|
+
### `get_docs`
|
|
71
|
+
|
|
72
|
+
Looks up the documentation for a class, method, or constant, reading from the exact gem versions locked in your app's Gemfile.lock.
|
|
73
|
+
|
|
74
|
+
[](https://asciinema.org/a/Aa0u915syzncFYH8)
|
|
75
|
+
|
|
76
|
+
Your agent can use it when it's unsure how a class or method works, so the code it generates is grounded in the docs for the exact versions of the gems your app uses, rather than training data that may be stale or a generic docs lookup that can't guarantee it matches the version your app depends on.
|
|
77
|
+
|
|
78
|
+
### `get_logs`
|
|
79
|
+
|
|
80
|
+
Returns output from your running server's log.
|
|
81
|
+
|
|
82
|
+
[](https://asciinema.org/a/1260413)
|
|
83
|
+
|
|
84
|
+
Your agent can use it to see what happened after a request. For example, reading the request log and backtrace when something misbehaves, or checking the log after an action to confirm the request came in with the expected params.
|
|
85
|
+
|
|
86
|
+
### `get_models`
|
|
87
|
+
|
|
88
|
+
Lists all of your app's models and where each one is defined, by file and line.
|
|
89
|
+
|
|
90
|
+
[](https://asciinema.org/a/qeELC7wEgMEI5T7n)
|
|
91
|
+
|
|
92
|
+
Your agent can use it to map the data domain and find where each model lives before opening files, rather than grepping around for class definitions.
|
|
93
|
+
|
|
94
|
+
### `get_source_location`
|
|
95
|
+
|
|
96
|
+
Returns the file and line where a class, module, or method is defined, across both your app and its dependencies.
|
|
97
|
+
|
|
98
|
+
[](https://asciinema.org/a/1260415)
|
|
99
|
+
|
|
100
|
+
Your agent can use it to jump straight to where a class or method is defined, by file and line, instead of grepping for it, including when the definition lives in a gem dependency.
|
|
101
|
+
|
|
102
|
+
Also, because it resolves the location from your running app instead of parsing source text, it handles metaprogramming, where a method is generated at runtime and does not appear as a literal `def` for grep to find.
|
|
103
|
+
|
|
104
|
+
> [!NOTE]
|
|
105
|
+
> #### Why no tools for routes, associations, etc?
|
|
106
|
+
>
|
|
107
|
+
> Tidewave does not include tools for listing your routes, associations, etc. because
|
|
108
|
+
> agents are better off reading their respective source files, which gives agents more
|
|
109
|
+
> context and enables them to perform any necessary edit without additional tool calls.
|
|
110
|
+
>
|
|
111
|
+
> Instead, Tidewave aims to fill in missing gaps, such as evaluating code inside your
|
|
112
|
+
> Rails app (without starting new instances) and finding source location, which can be
|
|
113
|
+
> tricky, even with grepping, due to metaprogramming and the different places Bundler
|
|
114
|
+
> can install your dependencies.
|
|
22
115
|
|
|
23
116
|
## Troubleshooting
|
|
24
117
|
|
|
118
|
+
### The Tidewave toolbar is missing
|
|
119
|
+
|
|
120
|
+
This may happen if you are compressing your responses (gzip, brotli, etc) after the Tidewave middleware runs. Use `bin/rails middleware` and make sure Tidewave comes after `Rack::Deflater` or similar. Also look into your browser and terminal logs for any errors.
|
|
121
|
+
|
|
25
122
|
### Using multiple hosts/subdomains
|
|
26
123
|
|
|
27
124
|
If you are using multiple hosts/subdomains during development, you must use `*.localhost`, as such domains are considered secure by browsers. Additionally, add the following to `config/initializers/development.rb`:
|
|
@@ -40,7 +137,7 @@ The above will allow your application to run embedded within Tidewave across mul
|
|
|
40
137
|
|
|
41
138
|
### Content security policy
|
|
42
139
|
|
|
43
|
-
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. It also disables the `frame-ancestors` directive.
|
|
140
|
+
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. It also disables the `frame-ancestors` directive. This is done only in the environments that Tidewave is loadead (development by default).
|
|
44
141
|
|
|
45
142
|
### Production Environment
|
|
46
143
|
|
|
@@ -66,37 +163,7 @@ The following config is available:
|
|
|
66
163
|
|
|
67
164
|
* `team` - set your Tidewave Team configuration, such as `config.tidewave.team = { id: "my-company" }`
|
|
68
165
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
- `execute_sql_query` - executes a SQL query within your application
|
|
72
|
-
database, useful for the agent to verify the result of an action
|
|
73
|
-
|
|
74
|
-
- `get_docs` - get the documentation for a given module/class/method.
|
|
75
|
-
It consults the exact versions used by the project, ensuring you always
|
|
76
|
-
get correct information
|
|
77
|
-
|
|
78
|
-
- `get_logs` - reads logs written by the server
|
|
79
|
-
|
|
80
|
-
- `get_models` - lists all modules in the application and their location
|
|
81
|
-
for quick discovery
|
|
82
|
-
|
|
83
|
-
- `get_source_location` - get the source location for a given module/class/method,
|
|
84
|
-
so an agent can directly read the source skipping search
|
|
85
|
-
|
|
86
|
-
- `project_eval` - evaluates code within the Rails application itself, giving the agent
|
|
87
|
-
access to your runtime, dependencies, and in-memory data
|
|
88
|
-
|
|
89
|
-
> [!NOTE]
|
|
90
|
-
> #### Why no tools for routes, associations, etc?
|
|
91
|
-
>
|
|
92
|
-
> Tidewave does not include tools for listing your routes, associations, etc. because
|
|
93
|
-
> agents are better off reading their respective source files, which gives agents more
|
|
94
|
-
> context and enable them to perform any necessary edit without additional tools calls.
|
|
95
|
-
>
|
|
96
|
-
> Instead, Tidewave aims to fill in missing gaps, such as evaluating code inside your
|
|
97
|
-
> Rails app (without starting new instances) and finding source location, which can be
|
|
98
|
-
> tricky, even with grepping, due to meta-programming and the different places Bundler
|
|
99
|
-
> can install your dependencies.
|
|
166
|
+
* `toolbar` - controls whether the Tidewave toolbar is injected into HTML pages. Defaults to `true`
|
|
100
167
|
|
|
101
168
|
## Acknowledgements
|
|
102
169
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
class Tidewave
|
|
4
4
|
class Configuration
|
|
5
|
-
attr_accessor :logger, :allow_remote_access, :preferred_orm, :dev, :client_url, :team, :logger_middleware
|
|
5
|
+
attr_accessor :logger, :allow_remote_access, :preferred_orm, :dev, :client_url, :team, :logger_middleware, :toolbar
|
|
6
6
|
|
|
7
7
|
def initialize
|
|
8
8
|
# Rails has a hosts middleware which already checks for this
|
|
@@ -13,6 +13,7 @@ class Tidewave
|
|
|
13
13
|
@client_url = "https://tidewave.ai"
|
|
14
14
|
@team = {}
|
|
15
15
|
@logger_middleware = nil
|
|
16
|
+
@toolbar = true
|
|
16
17
|
end
|
|
17
18
|
end
|
|
18
19
|
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Tidewave
|
|
4
|
+
module MagicBytes
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def type(bytes)
|
|
8
|
+
case bytes
|
|
9
|
+
when /\A\xFF\xD8\xFF/n
|
|
10
|
+
:jpg
|
|
11
|
+
when /\A\x89PNG\r\n\x1A\n/n
|
|
12
|
+
:png
|
|
13
|
+
when /\A\x1A\x45\xDF\xA3/n
|
|
14
|
+
bytes.include?("webm") ? :webm : :unknown
|
|
15
|
+
else
|
|
16
|
+
:unknown
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
data/lib/tidewave/railtie.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "logger"
|
|
4
|
+
require "uri"
|
|
4
5
|
require "tidewave/configuration"
|
|
5
6
|
require "tidewave/exceptions_middleware"
|
|
6
7
|
require "tidewave/quiet_requests_middleware"
|
|
@@ -24,6 +25,7 @@ class Tidewave
|
|
|
24
25
|
framework_type: "rails",
|
|
25
26
|
project_name: app.class.module_parent.name,
|
|
26
27
|
team: tidewave_config.team,
|
|
28
|
+
toolbar: tidewave_config.toolbar,
|
|
27
29
|
logger: tidewave_config.logger || Rails.logger,
|
|
28
30
|
root: Rails.root,
|
|
29
31
|
log_file: Rails.root.join("log", "#{Rails.env}.log"),
|
|
@@ -35,11 +37,21 @@ class Tidewave
|
|
|
35
37
|
# If the user configured CSP, we need to alter it in dev
|
|
36
38
|
# to allow TC to run browser_eval.
|
|
37
39
|
app.config.content_security_policy.try do |content_security_policy|
|
|
38
|
-
content_security_policy.directives
|
|
40
|
+
directives = content_security_policy.directives
|
|
41
|
+
script_src = directives["script-src"] || directives["default-src"]&.dup
|
|
42
|
+
client_origin = URI.parse(tidewave_config.client_url.to_s).origin
|
|
43
|
+
|
|
44
|
+
script_src.try do
|
|
39
45
|
script_src << "'unsafe-eval'" unless script_src.include?("'unsafe-eval'")
|
|
46
|
+
script_src << client_origin unless script_src.include?(client_origin)
|
|
47
|
+
directives["script-src"] = script_src
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
directives["script-src-elem"].try do |script_src_elem|
|
|
51
|
+
script_src_elem << client_origin unless script_src_elem.include?(client_origin)
|
|
40
52
|
end
|
|
41
53
|
|
|
42
|
-
|
|
54
|
+
directives.delete("frame-ancestors")
|
|
43
55
|
end
|
|
44
56
|
end
|
|
45
57
|
end
|
|
@@ -48,6 +48,17 @@ class Tidewave::Tools::ExecuteSqlQuery < Tidewave::Tool
|
|
|
48
48
|
def call(arguments_hash)
|
|
49
49
|
query = arguments_hash.fetch("query")
|
|
50
50
|
arguments = arguments_hash.fetch("arguments", [])
|
|
51
|
-
@database_adapter.execute_query(query, arguments)
|
|
51
|
+
result = @database_adapter.execute_query(query, arguments)
|
|
52
|
+
|
|
53
|
+
preamble = if result[:row_count] > result[:rows].length
|
|
54
|
+
<<~TEXT
|
|
55
|
+
Query returned #{result[:row_count]} rows. Only the first #{result[:rows].length} rows are included in the result. Use your database's pagination syntax, such as LIMIT + OFFSET, to show more rows if applicable.
|
|
56
|
+
|
|
57
|
+
TEXT
|
|
58
|
+
else
|
|
59
|
+
""
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
preamble + result.inspect
|
|
52
63
|
end
|
|
53
64
|
end
|
data/lib/tidewave/version.rb
CHANGED
data/lib/tidewave.rb
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "cgi"
|
|
3
5
|
require "ipaddr"
|
|
4
6
|
require "json"
|
|
7
|
+
require "pathname"
|
|
5
8
|
require "rack/request"
|
|
9
|
+
require "uri"
|
|
6
10
|
require "tidewave/version"
|
|
7
11
|
require "tidewave/tool"
|
|
8
12
|
require "tidewave/database_adapter"
|
|
13
|
+
require "tidewave/magic_bytes"
|
|
9
14
|
require "tidewave/railtie" if defined?(Rails::Railtie)
|
|
10
15
|
|
|
11
16
|
class Tidewave
|
|
@@ -25,10 +30,56 @@ Dir[gem_tools_path].sort.each do |file|
|
|
|
25
30
|
end
|
|
26
31
|
|
|
27
32
|
class Tidewave
|
|
33
|
+
class ToolbarBody
|
|
34
|
+
def initialize(body, toolbar)
|
|
35
|
+
@body = body
|
|
36
|
+
@toolbar = toolbar
|
|
37
|
+
@closed = false
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def each
|
|
41
|
+
return enum_for(:each) unless block_given?
|
|
42
|
+
|
|
43
|
+
pending = +""
|
|
44
|
+
injected = false
|
|
45
|
+
|
|
46
|
+
@body.each do |part|
|
|
47
|
+
if injected
|
|
48
|
+
yield part
|
|
49
|
+
else
|
|
50
|
+
pending << part
|
|
51
|
+
|
|
52
|
+
if closing_head = pending.downcase.index("</head>")
|
|
53
|
+
toolbar = @toolbar.dup.force_encoding(pending.encoding)
|
|
54
|
+
output = pending.insert(closing_head, toolbar)
|
|
55
|
+
pending = nil
|
|
56
|
+
injected = true
|
|
57
|
+
yield output
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
yield pending unless injected || pending.empty?
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def close
|
|
66
|
+
return if @closed
|
|
67
|
+
|
|
68
|
+
@closed = true
|
|
69
|
+
@body.close if @body.respond_to?(:close)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
28
73
|
TIDEWAVE_ROUTE = "tidewave".freeze
|
|
29
74
|
MCP_ROUTE = "mcp".freeze
|
|
30
75
|
CONFIG_ROUTE = "config".freeze
|
|
76
|
+
APP_ROUTE = "app".freeze
|
|
77
|
+
UPLOAD_ROUTE = "upload".freeze
|
|
31
78
|
PROTOCOL_VERSION = "2025-03-26".freeze
|
|
79
|
+
MAX_UPLOAD_SIZE = 10_000_000
|
|
80
|
+
ALLOWED_UPLOAD_CONTENT_TYPES = [ "image/png", "image/jpeg", "video/webm" ].freeze
|
|
81
|
+
ALLOWED_UPLOAD_TYPES = [ "screenshot", "recording" ].freeze
|
|
82
|
+
TMP_DIR = "tmp".freeze
|
|
32
83
|
|
|
33
84
|
INVALID_IP = <<~TEXT.freeze
|
|
34
85
|
For security reasons, Tidewave does not accept remote connections by default.
|
|
@@ -37,12 +88,19 @@ class Tidewave
|
|
|
37
88
|
TEXT
|
|
38
89
|
|
|
39
90
|
INVALID_ORIGIN = "For security reasons, Tidewave does not accept requests with an origin header for this endpoint.".freeze
|
|
91
|
+
INVALID_UPLOAD = "Bad Request: missing or invalid file parameter".freeze
|
|
92
|
+
ENCODED_HTML_WARNING = <<~TEXT.freeze
|
|
93
|
+
Tidewave could not inject the toolbar because the HTML response is encoded.
|
|
94
|
+
|
|
95
|
+
If you use Rack::Deflater or another compression middleware, place it before Tidewave in the middleware stack.
|
|
96
|
+
TEXT
|
|
40
97
|
|
|
41
98
|
DEFAULT_OPTIONS = {
|
|
42
99
|
allow_remote_access: false,
|
|
43
100
|
client_url: "https://tidewave.ai",
|
|
44
101
|
framework_type: "rack",
|
|
45
|
-
team: {}
|
|
102
|
+
team: {},
|
|
103
|
+
toolbar: true
|
|
46
104
|
}.freeze
|
|
47
105
|
|
|
48
106
|
def initialize(app, options = {})
|
|
@@ -51,6 +109,7 @@ class Tidewave
|
|
|
51
109
|
raise ArgumentError, "project_name is required" if @options[:project_name].to_s.empty?
|
|
52
110
|
|
|
53
111
|
@logger = @options[:logger]
|
|
112
|
+
@root = @options[:root] ? Pathname.new(@options[:root].to_s) : Pathname.pwd
|
|
54
113
|
@tools = build_tool_registry
|
|
55
114
|
end
|
|
56
115
|
|
|
@@ -60,22 +119,27 @@ class Tidewave
|
|
|
60
119
|
|
|
61
120
|
if path[0] == TIDEWAVE_ROUTE
|
|
62
121
|
return forbidden(INVALID_IP) unless valid_client_ip?(request)
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
end
|
|
122
|
+
|
|
123
|
+
return forbidden(INVALID_ORIGIN) if request.get_header("HTTP_ORIGIN") && !origin_allowed_path?(path)
|
|
66
124
|
|
|
67
125
|
case [ request.request_method, path ]
|
|
68
126
|
when [ "GET", [ TIDEWAVE_ROUTE ] ]
|
|
69
127
|
home_endpoint(request)
|
|
128
|
+
when [ "GET", [ TIDEWAVE_ROUTE, APP_ROUTE ] ]
|
|
129
|
+
app_endpoint(request)
|
|
70
130
|
when [ "GET", [ TIDEWAVE_ROUTE, CONFIG_ROUTE ] ]
|
|
71
131
|
config_endpoint(request)
|
|
72
132
|
when [ "POST", [ TIDEWAVE_ROUTE, MCP_ROUTE ] ]
|
|
73
133
|
mcp_endpoint(request)
|
|
134
|
+
when [ "POST", [ TIDEWAVE_ROUTE, UPLOAD_ROUTE ] ]
|
|
135
|
+
upload_endpoint(request)
|
|
74
136
|
else
|
|
75
|
-
|
|
137
|
+
# The MCP Streamable HTTP transport requires the MCP endpoint to answer
|
|
138
|
+
# non-POST methods with 405 (GET without SSE support, DELETE, etc.)
|
|
139
|
+
path == [ TIDEWAVE_ROUTE, MCP_ROUTE ] ? method_not_allowed() : not_found()
|
|
76
140
|
end
|
|
77
141
|
else
|
|
78
|
-
strip_x_frame_options(@app.call(env))
|
|
142
|
+
inject_toolbar(request, strip_x_frame_options(@app.call(env)))
|
|
79
143
|
end
|
|
80
144
|
end
|
|
81
145
|
|
|
@@ -83,7 +147,7 @@ class Tidewave
|
|
|
83
147
|
|
|
84
148
|
def strip_x_frame_options(response)
|
|
85
149
|
status, headers, body = response
|
|
86
|
-
headers.delete("
|
|
150
|
+
headers.delete("x-frame-options")
|
|
87
151
|
[ status, headers, body ]
|
|
88
152
|
end
|
|
89
153
|
|
|
@@ -104,28 +168,66 @@ class Tidewave
|
|
|
104
168
|
[ 200, response_headers("text/html", body), [ body ] ]
|
|
105
169
|
end
|
|
106
170
|
|
|
171
|
+
def app_endpoint(_request)
|
|
172
|
+
client_url = @options[:client_url].to_s.sub(%r{/\z}, "")
|
|
173
|
+
body = <<~HTML
|
|
174
|
+
<!DOCTYPE html>
|
|
175
|
+
<html>
|
|
176
|
+
<head>
|
|
177
|
+
<meta charset="UTF-8" />
|
|
178
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
179
|
+
<script type="module" src="#{client_url}/tc/control.js"></script>
|
|
180
|
+
</head>
|
|
181
|
+
<body></body>
|
|
182
|
+
</html>
|
|
183
|
+
HTML
|
|
184
|
+
|
|
185
|
+
headers = response_headers("text/html", body)
|
|
186
|
+
headers["content-security-policy"] = "base-uri 'self'; frame-ancestors 'self';"
|
|
187
|
+
[ 200, headers, [ body ] ]
|
|
188
|
+
end
|
|
189
|
+
|
|
107
190
|
def config_endpoint(request)
|
|
108
|
-
json_response(config_data(request), headers: { "
|
|
191
|
+
json_response(config_data(request), headers: { "access-control-allow-origin" => "*" })
|
|
109
192
|
end
|
|
110
193
|
|
|
111
194
|
def mcp_endpoint(request)
|
|
112
|
-
|
|
195
|
+
message = JSON.parse(request.body.read)
|
|
113
196
|
|
|
114
|
-
message
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
return json_response({ "status" => "ok" }, status: 202) if response.nil?
|
|
120
|
-
|
|
121
|
-
json_response(response)
|
|
197
|
+
if message.is_a?(Array)
|
|
198
|
+
handle_mcp_batch(message)
|
|
199
|
+
else
|
|
200
|
+
handle_mcp_single(message)
|
|
201
|
+
end
|
|
122
202
|
rescue JSON::ParserError
|
|
123
|
-
jsonrpc_error_response(nil, -32700, "Parse error")
|
|
203
|
+
jsonrpc_error_response(nil, -32700, "Parse error", status: 400)
|
|
124
204
|
rescue StandardError => error
|
|
125
205
|
@logger&.error("Error handling MCP request: #{error.message}")
|
|
126
206
|
jsonrpc_error_response(nil, -32603, "Internal error")
|
|
127
207
|
end
|
|
128
208
|
|
|
209
|
+
def handle_mcp_single(message)
|
|
210
|
+
validation_error = validate_jsonrpc_message(message)
|
|
211
|
+
return jsonrpc_error_response(nil, -32600, validation_error, status: 400) if validation_error
|
|
212
|
+
|
|
213
|
+
response = handle_mcp_message(message)
|
|
214
|
+
response.nil? ? accepted_response : json_response(response)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def handle_mcp_batch(messages)
|
|
218
|
+
return jsonrpc_error_response(nil, -32600, "Invalid Request", status: 400) if messages.empty?
|
|
219
|
+
|
|
220
|
+
responses = messages.map { |message| handle_mcp_batch_message(message) }.compact
|
|
221
|
+
responses.empty? ? accepted_response : json_response(responses)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def handle_mcp_batch_message(message)
|
|
225
|
+
validation_error = validate_jsonrpc_message(message)
|
|
226
|
+
return jsonrpc_error_response_body(nil, -32600, validation_error) if validation_error
|
|
227
|
+
|
|
228
|
+
handle_mcp_message(message)
|
|
229
|
+
end
|
|
230
|
+
|
|
129
231
|
def config_data(request)
|
|
130
232
|
{
|
|
131
233
|
"project_name" => @options[:project_name],
|
|
@@ -133,8 +235,90 @@ class Tidewave
|
|
|
133
235
|
"orm_adapter" => @options[:orm_adapter],
|
|
134
236
|
"team" => @options[:team] || {},
|
|
135
237
|
"tidewave_version" => VERSION,
|
|
136
|
-
"local_port" => local_port(request)
|
|
238
|
+
"local_port" => local_port(request),
|
|
239
|
+
"tmp_dir" => TMP_DIR
|
|
240
|
+
}
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def inject_toolbar(request, response)
|
|
244
|
+
status, headers, body = response
|
|
245
|
+
return response if @options[:toolbar] == false || !html_response?(headers)
|
|
246
|
+
|
|
247
|
+
if encoded_response?(headers)
|
|
248
|
+
warn_encoded_html
|
|
249
|
+
return response
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
return response unless body.respond_to?(:each)
|
|
253
|
+
|
|
254
|
+
delete_response_header(headers, "content-length")
|
|
255
|
+
delete_response_header(headers, "etag")
|
|
256
|
+
[ status, headers, ToolbarBody.new(body, toolbar_html(request)) ]
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def html_response?(headers)
|
|
260
|
+
content_types = Array(response_header(headers, "content-type"))
|
|
261
|
+
|
|
262
|
+
content_types.any? { |content_type| content_type.to_s.downcase.start_with?("text/html") }
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def encoded_response?(headers)
|
|
266
|
+
Array(response_header(headers, "content-encoding")).any? do |content_encoding|
|
|
267
|
+
content_encoding.to_s.split(",").any? do |encoding|
|
|
268
|
+
!encoding.strip.empty? && encoding.strip.downcase != "identity"
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def response_header(headers, name)
|
|
274
|
+
key = headers.keys.find { |header| header.downcase == name }
|
|
275
|
+
headers[key] if key
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def delete_response_header(headers, name)
|
|
279
|
+
headers.delete_if { |header, _value| header.downcase == name }
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def warn_encoded_html
|
|
283
|
+
return if @warned_encoded_html
|
|
284
|
+
|
|
285
|
+
@warned_encoded_html = true
|
|
286
|
+
@logger&.warn(ENCODED_HTML_WARNING)
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def toolbar_html(request)
|
|
290
|
+
client_url = @options[:client_url].to_s.sub(%r{/\z}, "")
|
|
291
|
+
payload = {
|
|
292
|
+
"tidewave" => config_data(request),
|
|
293
|
+
"root" => @root.to_s,
|
|
294
|
+
"wsl_distro" => ENV["WSL_DISTRO_NAME"],
|
|
295
|
+
"framework" => {}
|
|
137
296
|
}
|
|
297
|
+
|
|
298
|
+
<<~HTML
|
|
299
|
+
<meta name="tidewave:config" content="#{CGI.escapeHTML(JSON.generate(payload))}" />
|
|
300
|
+
<script async type="module" src="#{client_url}/tc/toolbar.js"></script>
|
|
301
|
+
HTML
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def upload_endpoint(request)
|
|
305
|
+
return text_response(400, INVALID_UPLOAD) if upload_too_large?(request)
|
|
306
|
+
|
|
307
|
+
params = request.POST
|
|
308
|
+
type = params["type"]
|
|
309
|
+
upload = normalize_upload(params["file"])
|
|
310
|
+
|
|
311
|
+
unless ALLOWED_UPLOAD_TYPES.include?(type) && allowed_upload?(upload)
|
|
312
|
+
return text_response(400, INVALID_UPLOAD)
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
FileUtils.mkdir_p(upload_dir(type))
|
|
316
|
+
destination = upload_path(type, upload[:filename])
|
|
317
|
+
FileUtils.cp(upload[:path], destination)
|
|
318
|
+
|
|
319
|
+
json_response({ "status" => "ok", "path" => relative_path_from_root(destination) })
|
|
320
|
+
rescue ArgumentError
|
|
321
|
+
text_response(400, INVALID_UPLOAD)
|
|
138
322
|
end
|
|
139
323
|
|
|
140
324
|
def json_response(payload, status: 200, headers: {})
|
|
@@ -151,19 +335,37 @@ class Tidewave
|
|
|
151
335
|
text_response(404, "Not Found")
|
|
152
336
|
end
|
|
153
337
|
|
|
338
|
+
def method_not_allowed
|
|
339
|
+
status, headers, body = text_response(405, "Method Not Allowed")
|
|
340
|
+
[ status, headers.merge("allow" => "POST"), body ]
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def accepted_response
|
|
344
|
+
[ 202, { "content-length" => "0" }, [] ]
|
|
345
|
+
end
|
|
346
|
+
|
|
154
347
|
def text_response(status, message)
|
|
155
348
|
[ status, response_headers("text/plain; charset=utf-8", message), [ message ] ]
|
|
156
349
|
end
|
|
157
350
|
|
|
351
|
+
# Rack 3 requires response header keys to be lowercase. Capitalized keys break
|
|
352
|
+
# case-sensitive middleware such as Rack::Deflater, which strips "content-length"
|
|
353
|
+
# before gzipping; a surviving "Content-Length" leaves a stale (uncompressed)
|
|
354
|
+
# length on the compressed body and hangs spec-compliant HTTP clients.
|
|
158
355
|
def response_headers(content_type, body)
|
|
159
356
|
{
|
|
160
|
-
"
|
|
161
|
-
"
|
|
357
|
+
"content-type" => content_type,
|
|
358
|
+
"content-length" => body.bytesize.to_s
|
|
162
359
|
}
|
|
163
360
|
end
|
|
164
361
|
|
|
165
362
|
def origin_allowed_path?(path)
|
|
166
|
-
|
|
363
|
+
[
|
|
364
|
+
[ TIDEWAVE_ROUTE ],
|
|
365
|
+
[ TIDEWAVE_ROUTE, APP_ROUTE ],
|
|
366
|
+
[ TIDEWAVE_ROUTE, CONFIG_ROUTE ],
|
|
367
|
+
[ TIDEWAVE_ROUTE, UPLOAD_ROUTE ]
|
|
368
|
+
].include?(path)
|
|
167
369
|
end
|
|
168
370
|
|
|
169
371
|
def local_port(request)
|
|
@@ -186,13 +388,74 @@ class Tidewave
|
|
|
186
388
|
false
|
|
187
389
|
end
|
|
188
390
|
|
|
391
|
+
def upload_too_large?(request)
|
|
392
|
+
request.content_length && request.content_length.to_i > MAX_UPLOAD_SIZE
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
def normalize_upload(upload)
|
|
396
|
+
case upload
|
|
397
|
+
when Hash
|
|
398
|
+
tempfile = upload[:tempfile] || upload["tempfile"]
|
|
399
|
+
{
|
|
400
|
+
filename: upload[:filename] || upload["filename"],
|
|
401
|
+
content_type: upload[:type] || upload["type"],
|
|
402
|
+
path: tempfile&.path
|
|
403
|
+
}
|
|
404
|
+
else
|
|
405
|
+
return {} unless upload.respond_to?(:original_filename) && upload.respond_to?(:content_type)
|
|
406
|
+
|
|
407
|
+
{
|
|
408
|
+
filename: upload.original_filename,
|
|
409
|
+
content_type: upload.content_type,
|
|
410
|
+
path: upload.tempfile&.path
|
|
411
|
+
}
|
|
412
|
+
end
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
def allowed_upload?(upload)
|
|
416
|
+
ALLOWED_UPLOAD_CONTENT_TYPES.include?(upload[:content_type].to_s.split(";").first) &&
|
|
417
|
+
upload[:path] &&
|
|
418
|
+
Tidewave::MagicBytes.type(File.binread(upload[:path], 128)) != :unknown
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
def upload_dir(type)
|
|
422
|
+
@root.join(TMP_DIR, "tidewave", folder_for_upload_type(type)).to_s
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
def upload_path(type, filename)
|
|
426
|
+
filename = filename.to_s
|
|
427
|
+
|
|
428
|
+
unless filename.match?(/\A[A-Za-z0-9_.-]+\z/) && !filename.include?("..")
|
|
429
|
+
raise ArgumentError, "filename must only contain numbers, letters, hyphens, and underscores: #{filename}"
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
unless [ ".png", ".jpg", ".jpeg", ".webm" ].include?(File.extname(filename).downcase)
|
|
433
|
+
raise ArgumentError, "filename must have a valid extension (.png, .jpg, .jpeg, .webm): #{filename}"
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
File.join(upload_dir(type), filename)
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
def folder_for_upload_type(type)
|
|
440
|
+
case type
|
|
441
|
+
when "screenshot"
|
|
442
|
+
"screenshots"
|
|
443
|
+
when "recording"
|
|
444
|
+
"recordings"
|
|
445
|
+
end
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
def relative_path_from_root(path)
|
|
449
|
+
Pathname.new(path).relative_path_from(@root).to_s
|
|
450
|
+
end
|
|
451
|
+
|
|
189
452
|
def validate_jsonrpc_message(message)
|
|
190
453
|
return "Message must be a JSON object" unless message.is_a?(Hash)
|
|
191
454
|
return "Invalid JSON-RPC version" unless message["jsonrpc"] == "2.0"
|
|
192
455
|
|
|
193
456
|
has_id = message.key?("id")
|
|
194
457
|
has_method = message.key?("method")
|
|
195
|
-
has_result = message.key?("result")
|
|
458
|
+
has_result = message.key?("result") || message.key?("error")
|
|
196
459
|
|
|
197
460
|
return nil if has_method
|
|
198
461
|
return nil if has_id && has_result
|
|
@@ -200,14 +463,17 @@ class Tidewave
|
|
|
200
463
|
"Invalid JSON-RPC message structure"
|
|
201
464
|
end
|
|
202
465
|
|
|
466
|
+
# Returns the JSON-RPC response for a request, or nil for messages that
|
|
467
|
+
# must not be replied to (notifications and client-sent responses), which
|
|
468
|
+
# the transport acknowledges with 202 Accepted.
|
|
203
469
|
def handle_mcp_message(message)
|
|
470
|
+
return nil unless message.key?("method") && message.key?("id")
|
|
471
|
+
|
|
204
472
|
method = message["method"]
|
|
205
473
|
request_id = message["id"]
|
|
206
474
|
params = message["params"].is_a?(Hash) ? message["params"] : {}
|
|
207
475
|
|
|
208
476
|
case method
|
|
209
|
-
when "notifications/initialized", "notifications/cancelled"
|
|
210
|
-
nil
|
|
211
477
|
when "ping"
|
|
212
478
|
jsonrpc_success_response_body(request_id, {})
|
|
213
479
|
when "initialize"
|
|
@@ -216,6 +482,12 @@ class Tidewave
|
|
|
216
482
|
jsonrpc_success_response_body(request_id, { "tools" => tool_definitions })
|
|
217
483
|
when "tools/call"
|
|
218
484
|
handle_tool_call(request_id, params)
|
|
485
|
+
when "prompts/list"
|
|
486
|
+
jsonrpc_success_response_body(request_id, { "prompts" => [] })
|
|
487
|
+
when "resources/list"
|
|
488
|
+
jsonrpc_success_response_body(request_id, { "resources" => [] })
|
|
489
|
+
when "resources/templates/list"
|
|
490
|
+
jsonrpc_success_response_body(request_id, { "resourceTemplates" => [] })
|
|
219
491
|
else
|
|
220
492
|
{
|
|
221
493
|
"jsonrpc" => "2.0",
|
|
@@ -233,14 +505,9 @@ class Tidewave
|
|
|
233
505
|
client_version = params["protocolVersion"]
|
|
234
506
|
return jsonrpc_error_response_body(request_id, -32602, "Protocol version is required") if client_version.nil? || client_version.empty?
|
|
235
507
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
-32602,
|
|
240
|
-
"Unsupported protocol version. Server supports #{PROTOCOL_VERSION} or later"
|
|
241
|
-
)
|
|
242
|
-
end
|
|
243
|
-
|
|
508
|
+
# Version negotiation: when the client requests a version we don't
|
|
509
|
+
# support, we respond with the version we do support and the client
|
|
510
|
+
# decides whether to continue or disconnect.
|
|
244
511
|
jsonrpc_success_response_body(request_id, {
|
|
245
512
|
"protocolVersion" => PROTOCOL_VERSION,
|
|
246
513
|
"capabilities" => { "tools" => { "listChanged" => false } },
|
|
@@ -276,8 +543,8 @@ class Tidewave
|
|
|
276
543
|
}
|
|
277
544
|
end
|
|
278
545
|
|
|
279
|
-
def jsonrpc_error_response(request_id, code, message)
|
|
280
|
-
json_response(jsonrpc_error_response_body(request_id, code, message))
|
|
546
|
+
def jsonrpc_error_response(request_id, code, message, status: 200)
|
|
547
|
+
json_response(jsonrpc_error_response_body(request_id, code, message), status: status)
|
|
281
548
|
end
|
|
282
549
|
|
|
283
550
|
def jsonrpc_error_response_body(request_id, code, message)
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: tidewave
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.8.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Yorick Jacquin
|
|
@@ -9,7 +9,7 @@ authors:
|
|
|
9
9
|
autorequire:
|
|
10
10
|
bindir: bin
|
|
11
11
|
cert_chain: []
|
|
12
|
-
date: 2026-
|
|
12
|
+
date: 2026-07-22 00:00:00.000000000 Z
|
|
13
13
|
dependencies:
|
|
14
14
|
- !ruby/object:Gem::Dependency
|
|
15
15
|
name: rack
|
|
@@ -41,6 +41,7 @@ files:
|
|
|
41
41
|
- lib/tidewave/database_adapters/active_record.rb
|
|
42
42
|
- lib/tidewave/database_adapters/sequel.rb
|
|
43
43
|
- lib/tidewave/exceptions_middleware.rb
|
|
44
|
+
- lib/tidewave/magic_bytes.rb
|
|
44
45
|
- lib/tidewave/quiet_requests_middleware.rb
|
|
45
46
|
- lib/tidewave/railtie.rb
|
|
46
47
|
- lib/tidewave/tool.rb
|