active_webmcp 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/CHANGELOG.md +32 -0
- data/LICENSE +21 -0
- data/README.md +284 -0
- data/SECURITY.md +65 -0
- data/app/assets/javascripts/active_webmcp/runtime.js +262 -0
- data/app/assets/javascripts/active_webmcp.js +21 -0
- data/lib/active_webmcp/controller.rb +28 -0
- data/lib/active_webmcp/engine.rb +14 -0
- data/lib/active_webmcp/page_helper.rb +99 -0
- data/lib/active_webmcp/schema_compiler.rb +106 -0
- data/lib/active_webmcp/tool_definition.rb +77 -0
- data/lib/active_webmcp/version.rb +5 -0
- data/lib/active_webmcp.rb +31 -0
- data/lib/generators/active_webmcp/install/install_generator.rb +81 -0
- metadata +131 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 8f08c4bcae7954bc89775cb6d930aa1d26f862e8d0b99e51257c814c8a24cd52
|
|
4
|
+
data.tar.gz: 33f7fcbd7405270ef9880809d673150a31c71b939c57a914402a72e21d8f9bde
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 647e2f2d323ce910501d46c1bfab3a9d21a49273931029a7c0f985e8e805a56576dc257224d02e6bd57f4900cb05edbf4e94a16a091904a3bbf47c2807c409b1
|
|
7
|
+
data.tar.gz: 3d378d826938e3babcf7426b4052870fe37a7d69d57c10fe0bfaa0192bf99752b1a5ce6bb4cbbc8c1401bda0a53f9318a73a6b6f3a88c6610c2b7c6e5b9cf201
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 — 2026-09-01
|
|
4
|
+
|
|
5
|
+
- Explicit controller declarations and page-selected public tool names; local and
|
|
6
|
+
explicit cross-controller lookup, immutable schemas and Rails reload support.
|
|
7
|
+
- Flat string/integer/number/boolean parameters with descriptions, required fields
|
|
8
|
+
and enums; strict validation, no coercion or agent-controlled routing.
|
|
9
|
+
- Named same-origin GET/POST JSON execution with Rails sessions and current CSRF.
|
|
10
|
+
- `{ status, httpStatus, data, error }` results, 202/204 handling, blocked redirects,
|
|
11
|
+
safe error codes, transfer-wide configurable timeout and unknown write outcomes.
|
|
12
|
+
- Native WebMCP adapter, document ownership, Turbo Drive/cache restoration,
|
|
13
|
+
cleanup independent of execution cancellation, and unsupported-browser fallback.
|
|
14
|
+
- Optional human-readable tool titles plus read-only and untrusted-content
|
|
15
|
+
annotations from the current WebMCP draft.
|
|
16
|
+
- Idempotent Rails 8.1 importmap/Propshaft installer; nonce CSP and production
|
|
17
|
+
precompiled assets verified on the pinned setup.
|
|
18
|
+
- Search/favourite examples and request, Node and pinned native-browser checks.
|
|
19
|
+
- Local gem-package installation verification with isolated gem home and fresh
|
|
20
|
+
Rails app; no package publication is performed by verification scripts.
|
|
21
|
+
|
|
22
|
+
### Changes during development
|
|
23
|
+
|
|
24
|
+
The first development implementation returned raw endpoint JSON to a native caller. This release instead
|
|
25
|
+
wraps it under `data`; the Rails HTTP response format is unchanged.
|
|
26
|
+
|
|
27
|
+
### Known limits
|
|
28
|
+
|
|
29
|
+
Pinned experimental Chrome only; no polyfill, separate MCP server, UI updates,
|
|
30
|
+
navigation execution, forms, dynamic paths, nested inputs, Frames/Streams,
|
|
31
|
+
framework adapters or API-only/cross-origin integration. Browser replay requires
|
|
32
|
+
application deduplication. MIT licensed.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 zbin.song@gmail.com
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
# ActiveWebMCP
|
|
2
|
+
|
|
3
|
+
[](https://github.com/benjis/Active_WebMCP/actions/workflows/ci.yml)
|
|
4
|
+
|
|
5
|
+
Turn selected Rails controller actions into tools that browser-based AI agents
|
|
6
|
+
can discover and call.
|
|
7
|
+
|
|
8
|
+
ActiveWebMCP keeps your application in control. Your existing Rails actions still
|
|
9
|
+
handle authentication, authorization, validation, database writes, and JSON
|
|
10
|
+
responses. You do not need a separate MCP server or handwritten registration
|
|
11
|
+
JavaScript.
|
|
12
|
+
|
|
13
|
+
## Status and WebMCP support
|
|
14
|
+
|
|
15
|
+
`0.1.0` is the current release.
|
|
16
|
+
|
|
17
|
+
WebMCP does not currently have numbered protocol releases. ActiveWebMCP targets
|
|
18
|
+
the [W3C WebMCP Draft Community Group Report dated 26 August 2026](https://webmachinelearning.github.io/webmcp/),
|
|
19
|
+
at upstream revision
|
|
20
|
+
[`41d12f0`](https://github.com/webmachinelearning/webmcp/commit/41d12f057167ccf5954dbcf49d99502cb6c84491).
|
|
21
|
+
It supports the draft's imperative `document.modelContext` API:
|
|
22
|
+
|
|
23
|
+
- tool registration with `name`, `title`, `description`, `inputSchema`,
|
|
24
|
+
`annotations`, and `execute`
|
|
25
|
+
- registration cleanup with `AbortSignal`
|
|
26
|
+
- execution cancellation with `AbortSignal`
|
|
27
|
+
- JSON-serializable tool results
|
|
28
|
+
|
|
29
|
+
This release is tested with Chrome for Testing `153.0.8010.12` on Apple
|
|
30
|
+
Silicon macOS. Chrome exposes WebMCP experimentally; it is not yet a stable,
|
|
31
|
+
widely available browser API. ActiveWebMCP does not currently support declarative
|
|
32
|
+
form tools or cross-origin tool exposure.
|
|
33
|
+
|
|
34
|
+
## Requirements
|
|
35
|
+
|
|
36
|
+
- Ruby 3.3 or newer
|
|
37
|
+
- Rails 8.1
|
|
38
|
+
- importmap-rails
|
|
39
|
+
- Propshaft
|
|
40
|
+
- a browser with the experimental WebMCP API
|
|
41
|
+
|
|
42
|
+
The tested Rails stack is Ruby `3.3.12`, Rails `8.1.3.1`, importmap-rails `2.2.3`,
|
|
43
|
+
and Propshaft `1.3.2`.
|
|
44
|
+
|
|
45
|
+
## Quick start
|
|
46
|
+
|
|
47
|
+
### 1. Add the gem
|
|
48
|
+
|
|
49
|
+
For development from a nearby checkout:
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
# Gemfile
|
|
53
|
+
gem "active_webmcp", path: "../Active_WebMCP"
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
For the published `0.1.0` release:
|
|
57
|
+
|
|
58
|
+
```ruby
|
|
59
|
+
# Gemfile
|
|
60
|
+
gem "active_webmcp", "0.1.0"
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Then install the JavaScript entrypoint and importmap pins:
|
|
64
|
+
|
|
65
|
+
```sh
|
|
66
|
+
bundle install
|
|
67
|
+
bin/rails generate active_webmcp:install
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
The generator is safe to run more than once. It stops without changing files if
|
|
71
|
+
it finds an importmap conflict or an unsupported layout.
|
|
72
|
+
|
|
73
|
+
### 2. Declare a read tool
|
|
74
|
+
|
|
75
|
+
Start with an existing controller action that returns JSON:
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
class HotelsController < ApplicationController
|
|
79
|
+
webmcp_tool :search,
|
|
80
|
+
name: "search_hotels",
|
|
81
|
+
title: "Search hotels",
|
|
82
|
+
description: "Search hotels by destination. Does not book or pay.",
|
|
83
|
+
read_only_hint: true,
|
|
84
|
+
untrusted_content_hint: true,
|
|
85
|
+
route: :search_hotels,
|
|
86
|
+
method: :get,
|
|
87
|
+
parameters: {
|
|
88
|
+
destination: {
|
|
89
|
+
type: :string,
|
|
90
|
+
required: true,
|
|
91
|
+
description: "City or region"
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
def search
|
|
96
|
+
hotels = Hotel.where(destination: params.require(:destination))
|
|
97
|
+
render json: { hotels: hotels.as_json(only: %i[id name destination]) }
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Use a named route:
|
|
103
|
+
|
|
104
|
+
```ruby
|
|
105
|
+
# config/routes.rb
|
|
106
|
+
get "/hotels/search", to: "hotels#search", as: :search_hotels
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### 3. Select the tool on a page
|
|
110
|
+
|
|
111
|
+
A declared tool is not exposed automatically. Select it in the view where it
|
|
112
|
+
should be available:
|
|
113
|
+
|
|
114
|
+
```erb
|
|
115
|
+
<%= webmcp_tools "search_hotels" %>
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
That is enough. ActiveWebMCP renders safe JSON metadata and registers the tool
|
|
119
|
+
when the page loads. If WebMCP is unavailable, the rest of the page continues to
|
|
120
|
+
work normally.
|
|
121
|
+
|
|
122
|
+
### Optional tool metadata
|
|
123
|
+
|
|
124
|
+
Use `title` as a short label that a browser or agent can show to a person. The
|
|
125
|
+
description can remain a fuller explanation of what the tool does.
|
|
126
|
+
|
|
127
|
+
Two optional annotations describe expected behaviour:
|
|
128
|
+
|
|
129
|
+
- `read_only_hint: true` says the tool should not change application state.
|
|
130
|
+
- `untrusted_content_hint: true` says the result may include content that should
|
|
131
|
+
not be treated as trusted instructions.
|
|
132
|
+
|
|
133
|
+
These annotations are hints for clients. They do not enforce permissions,
|
|
134
|
+
sandbox content, or replace authentication, authorization, and input validation.
|
|
135
|
+
|
|
136
|
+
## Write tool example
|
|
137
|
+
|
|
138
|
+
POST tools use the same Rails session and CSRF protection as the rest of your app:
|
|
139
|
+
|
|
140
|
+
```ruby
|
|
141
|
+
class FavouritesController < ApplicationController
|
|
142
|
+
before_action :authenticate_user!
|
|
143
|
+
|
|
144
|
+
webmcp_tool :create,
|
|
145
|
+
name: "add_favourite",
|
|
146
|
+
title: "Add favourite",
|
|
147
|
+
description: "Save a hotel to the current user's favourites. Does not book it.",
|
|
148
|
+
route: :favourites,
|
|
149
|
+
method: :post,
|
|
150
|
+
parameters: {
|
|
151
|
+
hotel_id: { type: :integer, required: true }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
def create
|
|
155
|
+
favourite = current_user.favourites.create_or_find_by!(
|
|
156
|
+
hotel_id: params.require(:hotel_id)
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
render json: { hotel_id: favourite.hotel_id, saved: true }
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
```ruby
|
|
165
|
+
# config/routes.rb
|
|
166
|
+
post "/favourites", to: "favourites#create", as: :favourites
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Select a tool from another controller explicitly:
|
|
170
|
+
|
|
171
|
+
```erb
|
|
172
|
+
<%= webmcp_tools "add_favourite", controller: FavouritesController %>
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
ActiveWebMCP reads the current CSRF token when the agent invokes the tool. Rails
|
|
176
|
+
must still authenticate the user, authorize the record, and validate every input.
|
|
177
|
+
Use database constraints or another idempotency strategy for writes because a
|
|
178
|
+
browser or network intermediary may replay a POST.
|
|
179
|
+
|
|
180
|
+
## Supported parameters
|
|
181
|
+
|
|
182
|
+
ActiveWebMCP intentionally supports a small, predictable schema:
|
|
183
|
+
|
|
184
|
+
| Type | Ruby declaration | Example value |
|
|
185
|
+
| --- | --- | --- |
|
|
186
|
+
| String | `type: :string` | `"Sydney"` |
|
|
187
|
+
| Integer | `type: :integer` | `42` |
|
|
188
|
+
| Number | `type: :number` | `12.5` |
|
|
189
|
+
| Boolean | `type: :boolean` | `true` |
|
|
190
|
+
|
|
191
|
+
Each parameter can use `required`, `description`, and a type-matching `enum`.
|
|
192
|
+
Nested objects, arrays, default values, silent type conversion, and unknown input
|
|
193
|
+
fields are rejected.
|
|
194
|
+
|
|
195
|
+
## Tool results
|
|
196
|
+
|
|
197
|
+
The Rails action owns the JSON inside `data`. ActiveWebMCP wraps it in a small
|
|
198
|
+
result envelope:
|
|
199
|
+
|
|
200
|
+
```json
|
|
201
|
+
{
|
|
202
|
+
"status": "completed",
|
|
203
|
+
"httpStatus": 200,
|
|
204
|
+
"data": {
|
|
205
|
+
"hotels": [
|
|
206
|
+
{ "id": 1, "name": "Harbour Hotel", "destination": "Sydney" }
|
|
207
|
+
]
|
|
208
|
+
},
|
|
209
|
+
"error": null
|
|
210
|
+
}
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
`status` can be `completed`, `accepted`, `failed`, or `unknown`. An interrupted
|
|
214
|
+
write can be `unknown` because a timeout cannot prove whether the server committed
|
|
215
|
+
the change. ActiveWebMCP never retries a tool request automatically.
|
|
216
|
+
|
|
217
|
+
## Configuration
|
|
218
|
+
|
|
219
|
+
The default timeout is 30 seconds. Change it in an initializer:
|
|
220
|
+
|
|
221
|
+
```ruby
|
|
222
|
+
# config/initializers/active_webmcp.rb
|
|
223
|
+
Rails.application.config.active_webmcp.timeout_ms = 10_000
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
Keep these Rails helpers in your layout:
|
|
227
|
+
|
|
228
|
+
```erb
|
|
229
|
+
<%= csrf_meta_tags %>
|
|
230
|
+
<%= csp_meta_tag %>
|
|
231
|
+
<%= javascript_importmap_tags %>
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
The installer does not weaken your Content Security Policy.
|
|
235
|
+
|
|
236
|
+
## Important boundaries
|
|
237
|
+
|
|
238
|
+
- Only explicitly selected tools are visible on a page.
|
|
239
|
+
- Only named, same-origin GET and POST routes are supported.
|
|
240
|
+
- Dynamic path segments and wildcard routes are not supported.
|
|
241
|
+
- Authentication and authorization remain application responsibilities.
|
|
242
|
+
- Tool annotations are advisory metadata, not security controls.
|
|
243
|
+
- Tool descriptions and results may contain untrusted text.
|
|
244
|
+
- Turbo Drive navigation and page restoration are supported.
|
|
245
|
+
- Turbo Frames, Streams, morph navigation, and custom routers are not supported.
|
|
246
|
+
- Missing WebMCP support must not replace or break the human interface.
|
|
247
|
+
|
|
248
|
+
See [SECURITY.md](SECURITY.md) before exposing actions that read private data or
|
|
249
|
+
change application state.
|
|
250
|
+
|
|
251
|
+
## Development
|
|
252
|
+
|
|
253
|
+
Run the fast Ruby and JavaScript suites:
|
|
254
|
+
|
|
255
|
+
```sh
|
|
256
|
+
bundle exec ruby bin/test
|
|
257
|
+
npm test
|
|
258
|
+
bundle exec rubocop
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
The Ruby suite uses RSpec. The JavaScript suite uses Node's built-in test runner.
|
|
262
|
+
|
|
263
|
+
Native browser tests require the pinned Apple Silicon Chrome build:
|
|
264
|
+
|
|
265
|
+
```sh
|
|
266
|
+
ruby bin/setup-browser
|
|
267
|
+
bundle exec ruby bin/test --native
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
Release verification builds isolated Rails applications and a real gem artifact:
|
|
271
|
+
|
|
272
|
+
```sh
|
|
273
|
+
bundle exec ruby bin/check-clean-install
|
|
274
|
+
bundle exec ruby bin/check-production
|
|
275
|
+
bundle exec ruby bin/check-package
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
The demonstration Rails application lives in `test/dummy` and contains only
|
|
279
|
+
synthetic users and records. It is never included in the gem.
|
|
280
|
+
|
|
281
|
+
## License
|
|
282
|
+
|
|
283
|
+
ActiveWebMCP is available under the [MIT License](LICENSE). See
|
|
284
|
+
[CHANGELOG.md](CHANGELOG.md) for release changes.
|
data/SECURITY.md
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Security guidance
|
|
2
|
+
|
|
3
|
+
ActiveWebMCP is an experimental browser enhancement, not an authorization layer.
|
|
4
|
+
Security reports should be submitted privately through
|
|
5
|
+
[GitHub Security Advisories](https://github.com/benjis/Active_WebMCP/security/advisories/new).
|
|
6
|
+
Do not put tokens, personal records, private responses or exploit details into a
|
|
7
|
+
public issue.
|
|
8
|
+
|
|
9
|
+
## Application responsibilities
|
|
10
|
+
|
|
11
|
+
- Authenticate every request, authorize the record and tenant, validate input and
|
|
12
|
+
enforce transactions. Tool visibility and descriptions are not permission grants.
|
|
13
|
+
- Keep Rails CSRF protection enabled and render current `csrf_meta_tags`. The gem
|
|
14
|
+
reads the token at invocation time; it cannot repair expired sessions or tokens.
|
|
15
|
+
- Use named GET routes only for reads and named POST routes for writes. Never put
|
|
16
|
+
writes behind GET. Preserve authorization for human and agent callers alike.
|
|
17
|
+
- Return only JSON you intend the caller/agent to see, including error responses.
|
|
18
|
+
The gem preserves application JSON under `data`; it does not redact it for you.
|
|
19
|
+
- Scope record queries and uniqueness constraints appropriately. The fixture's
|
|
20
|
+
`create_or_find_by!` and unique index are an example, not general idempotency for
|
|
21
|
+
arbitrary operations. Payments, refunds and irreversible actions are out of scope.
|
|
22
|
+
- Avoid shared caches for session-specific pages/CSRF metadata. Old browser snapshots
|
|
23
|
+
are not proof of current authorization. Own cache invalidation on identity changes.
|
|
24
|
+
- Configure Rails/application/proxy log filtering. Gem diagnostics contain static
|
|
25
|
+
codes; this does not control Rails request logs, browser logs or agent traces.
|
|
26
|
+
|
|
27
|
+
## Transport and outcomes
|
|
28
|
+
|
|
29
|
+
Each dispatched invocation makes one gem-level `fetch`. There is no automatic
|
|
30
|
+
retry, UI refresh request or rollback. A browser can transparently replay a POST
|
|
31
|
+
inside that fetch; design application-owned deduplication for duplicate delivery.
|
|
32
|
+
|
|
33
|
+
Timeout, cancellation or a lost/invalid successful POST response can leave the
|
|
34
|
+
outcome `unknown`. Do not blindly resubmit. A native caller's cancellation may
|
|
35
|
+
prevent delivery of any final envelope. A non-success HTTP status is not proof
|
|
36
|
+
that a transaction rolled back. HTTP 202 is accepted, not completed.
|
|
37
|
+
|
|
38
|
+
Requests use same-origin credentials, JSON Accept and manual redirects. Missing
|
|
39
|
+
CSRF prevents POST dispatch. Server-side CSRF and access checks remain mandatory.
|
|
40
|
+
Never supply routing decisions from agent inputs. The alpha rejects dynamic paths,
|
|
41
|
+
wildcards, cross-origin endpoints and reserved routing parameters.
|
|
42
|
+
|
|
43
|
+
## Browser, CSP and agent boundary
|
|
44
|
+
|
|
45
|
+
Only selected tool metadata is emitted, as escaped inert JSON. Executable code is
|
|
46
|
+
in packaged external modules; there is no eval or inline callback. Applications
|
|
47
|
+
own CSP. Rails importmap's inline bootstrap still needs an approved nonce/hash
|
|
48
|
+
policy; do not disable CSP just to make tools load.
|
|
49
|
+
|
|
50
|
+
Page text and tool results can contain prompt injection. This gem does not make
|
|
51
|
+
untrusted text safe instructions, constrain an agent's reasoning, or implement
|
|
52
|
+
confirmation for sensitive actions. A hostile same-origin script/extension is
|
|
53
|
+
outside this library's isolation guarantees.
|
|
54
|
+
|
|
55
|
+
Use pinned test browsers with isolated profiles. Inspector requests broad host
|
|
56
|
+
access and can retain its Gemini key: use fixture data only. Configure credentials
|
|
57
|
+
through Inspector's UI, never in source, CLI arguments, committed files or chat.
|
|
58
|
+
Keep profiles out of version control and rotate temporary test credentials.
|
|
59
|
+
|
|
60
|
+
## Reporting
|
|
61
|
+
|
|
62
|
+
Provide the gem/browser/Rails versions, affected boundary, sanitized reproduction
|
|
63
|
+
and whether a business write may have committed. Do not send real credentials or
|
|
64
|
+
tenant/customer data. Submitting a report does not create a guaranteed response
|
|
65
|
+
time or support commitment.
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
// Internal implementation. Only native WebMCP access lives in this adapter.
|
|
2
|
+
export class WebMCPAdapter {
|
|
3
|
+
constructor(document) { this.document = document; }
|
|
4
|
+
|
|
5
|
+
available() {
|
|
6
|
+
const context = this.document.modelContext;
|
|
7
|
+
return typeof context?.registerTool === "function" && typeof context?.getTools === "function";
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
async register(definition, execute) {
|
|
11
|
+
const lifetime = new AbortController();
|
|
12
|
+
const tool = { name: definition.name, description: definition.description,
|
|
13
|
+
inputSchema: definition.inputSchema, execute };
|
|
14
|
+
if (own(definition, "title")) tool.title = definition.title;
|
|
15
|
+
if (own(definition, "annotations")) tool.annotations = definition.annotations;
|
|
16
|
+
try {
|
|
17
|
+
await this.document.modelContext.registerTool(tool, { signal: lifetime.signal });
|
|
18
|
+
} catch (error) {
|
|
19
|
+
lifetime.abort();
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
// This lifetime is NOT the execution callback's signal.
|
|
23
|
+
return () => lifetime.abort();
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
class ExecutionError extends Error {
|
|
28
|
+
constructor(code) {
|
|
29
|
+
super(`ActiveWebMCP: ${code}`);
|
|
30
|
+
this.name = "ActiveWebMCPError";
|
|
31
|
+
this.code = code;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const own = (object, key) => Object.prototype.hasOwnProperty.call(object, key);
|
|
36
|
+
const scalar = (type, value) => {
|
|
37
|
+
switch (type) {
|
|
38
|
+
case "string": return typeof value === "string";
|
|
39
|
+
case "boolean": return typeof value === "boolean";
|
|
40
|
+
case "integer": return Number.isSafeInteger(value);
|
|
41
|
+
case "number": return typeof value === "number" && Number.isFinite(value);
|
|
42
|
+
default: return false;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
const reserved = new Set(["controller", "action", "format", "host", "protocol", "port", "url", "origin", "path",
|
|
46
|
+
"method", "script_name", "authenticity_token", "_method", "utf8", "__proto__", "prototype", "constructor"]);
|
|
47
|
+
|
|
48
|
+
function validateInput(schema, input) {
|
|
49
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) throw new ExecutionError("invalid_input");
|
|
50
|
+
for (const name of schema.required) {
|
|
51
|
+
if (!own(input, name)) throw new ExecutionError("missing_parameter");
|
|
52
|
+
}
|
|
53
|
+
for (const [name, value] of Object.entries(input)) {
|
|
54
|
+
if (!own(schema.properties, name)) throw new ExecutionError("unknown_parameter");
|
|
55
|
+
const property = schema.properties[name];
|
|
56
|
+
if (!scalar(property.type, value)) throw new ExecutionError("invalid_parameter_type");
|
|
57
|
+
if (property.enum && !property.enum.includes(value)) throw new ExecutionError("invalid_parameter_enum");
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function validateDefinition(definition, location) {
|
|
62
|
+
if (!definition || typeof definition.name !== "string" || !/^[a-zA-Z0-9_.-]{1,128}$/.test(definition.name) ||
|
|
63
|
+
typeof definition.description !== "string" || !definition.description.trim()) throw new ExecutionError("invalid_manifest");
|
|
64
|
+
if (own(definition, "title") && (typeof definition.title !== "string" || !definition.title.trim())) {
|
|
65
|
+
throw new ExecutionError("invalid_manifest");
|
|
66
|
+
}
|
|
67
|
+
validateAnnotations(definition);
|
|
68
|
+
const endpoint = definition.endpoint;
|
|
69
|
+
if (!["GET", "POST"].includes(endpoint?.method) || typeof endpoint.path !== "string" || !/^\/(?!\/)/.test(endpoint.path) ||
|
|
70
|
+
/[\\?#\x00-\x20]/.test(endpoint.path)) throw new ExecutionError("invalid_endpoint");
|
|
71
|
+
const url = new URL(endpoint.path, location.href);
|
|
72
|
+
if (url.origin !== location.origin) throw new ExecutionError("cross_origin_endpoint");
|
|
73
|
+
if (own(definition, "timeoutMs") && !validTimeout(definition.timeoutMs)) throw new ExecutionError("invalid_timeout");
|
|
74
|
+
const schema = definition.inputSchema;
|
|
75
|
+
if (schema?.type !== "object" || schema.additionalProperties !== false || !schema.properties ||
|
|
76
|
+
typeof schema.properties !== "object" || Array.isArray(schema.properties) || !Array.isArray(schema.required)) {
|
|
77
|
+
throw new ExecutionError("invalid_schema");
|
|
78
|
+
}
|
|
79
|
+
for (const name of schema.required) {
|
|
80
|
+
if (typeof name !== "string" || !own(schema.properties, name)) throw new ExecutionError("invalid_schema");
|
|
81
|
+
}
|
|
82
|
+
for (const [name, property] of Object.entries(schema.properties)) {
|
|
83
|
+
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name) || reserved.has(name) || !property ||
|
|
84
|
+
!["string", "number", "integer", "boolean"].includes(property.type)) throw new ExecutionError("invalid_schema");
|
|
85
|
+
if (own(property, "enum") && (!Array.isArray(property.enum) || !property.enum.length ||
|
|
86
|
+
!property.enum.every(value => scalar(property.type, value)))) throw new ExecutionError("invalid_schema");
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function validateAnnotations(definition) {
|
|
91
|
+
if (!own(definition, "annotations")) return;
|
|
92
|
+
const annotations = definition.annotations;
|
|
93
|
+
if (!annotations || typeof annotations !== "object" || Array.isArray(annotations)) {
|
|
94
|
+
throw new ExecutionError("invalid_manifest");
|
|
95
|
+
}
|
|
96
|
+
const supported = new Set(["readOnlyHint", "untrustedContentHint"]);
|
|
97
|
+
for (const [name, value] of Object.entries(annotations)) {
|
|
98
|
+
if (!supported.has(name) || typeof value !== "boolean") throw new ExecutionError("invalid_manifest");
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const validTimeout = value => Number.isInteger(value) && value > 0 && value <= 2147483647;
|
|
103
|
+
const messages = Object.freeze({
|
|
104
|
+
invalid_input: "Input must be a flat object.",
|
|
105
|
+
missing_parameter: "A required parameter is missing.",
|
|
106
|
+
unknown_parameter: "An unknown parameter was supplied.",
|
|
107
|
+
invalid_parameter_type: "A parameter has an invalid type.",
|
|
108
|
+
invalid_parameter_enum: "A parameter is outside its allowed values.",
|
|
109
|
+
missing_csrf: "A current Rails CSRF token is required before sending this write.",
|
|
110
|
+
invalid_csrf: "The Rails CSRF token cannot be used as a request header.",
|
|
111
|
+
http_error: "The server returned an unsuccessful HTTP status.",
|
|
112
|
+
redirect_blocked: "Redirects are not followed in JSON execution mode.",
|
|
113
|
+
non_json_response: "The server did not return a JSON response.",
|
|
114
|
+
invalid_json: "The server returned malformed JSON.",
|
|
115
|
+
timeout: "The request exceeded its time limit.",
|
|
116
|
+
cancelled: "The invocation was cancelled.",
|
|
117
|
+
request_failed: "The request or response transfer could not be completed."
|
|
118
|
+
});
|
|
119
|
+
function result(status, httpStatus = null, data = null, code = null) {
|
|
120
|
+
return { status, httpStatus, data, error: code ? { code,
|
|
121
|
+
message: (messages[code] || messages.request_failed) +
|
|
122
|
+
(status === "unknown" ? " The write outcome is unknown; do not automatically retry." : "") } : null };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// HTTP evidence is separate from business outcome. Never infer rollback from an
|
|
126
|
+
// interrupted write, and never change the application's JSON representation.
|
|
127
|
+
async function execute(definition, input, signal, { document, fetch, location, timeoutMs }) {
|
|
128
|
+
const request = new AbortController();
|
|
129
|
+
let dispatched = false;
|
|
130
|
+
let httpStatus = null;
|
|
131
|
+
let timeout;
|
|
132
|
+
let cancel;
|
|
133
|
+
let interruption;
|
|
134
|
+
const isWrite = definition.endpoint.method === "POST";
|
|
135
|
+
const incompleteStatus = () => {
|
|
136
|
+
if (httpStatus === 202) return "accepted";
|
|
137
|
+
if (httpStatus !== null && (httpStatus < 200 || httpStatus >= 300)) return "failed";
|
|
138
|
+
return isWrite && dispatched ? "unknown" : "failed";
|
|
139
|
+
};
|
|
140
|
+
try {
|
|
141
|
+
validateInput(definition.inputSchema, input);
|
|
142
|
+
if (signal?.aborted) throw new ExecutionError("cancelled");
|
|
143
|
+
const url = new URL(definition.endpoint.path, location.href);
|
|
144
|
+
const options = { method: definition.endpoint.method, credentials: "same-origin", redirect: "manual",
|
|
145
|
+
headers: { Accept: "application/json" }, signal: request.signal };
|
|
146
|
+
if (isWrite) {
|
|
147
|
+
// Read at invocation time, not when the page tools were registered.
|
|
148
|
+
const token = document.querySelector('meta[name="csrf-token"]')?.getAttribute("content");
|
|
149
|
+
if (typeof token !== "string" || !token.trim()) throw new ExecutionError("missing_csrf");
|
|
150
|
+
if (/[^\x20-\x7e]/.test(token)) throw new ExecutionError("invalid_csrf");
|
|
151
|
+
options.headers["X-CSRF-Token"] = token;
|
|
152
|
+
options.headers["Content-Type"] = "application/json";
|
|
153
|
+
options.body = JSON.stringify(Object.fromEntries(Object.entries(input)));
|
|
154
|
+
} else {
|
|
155
|
+
for (const [name, value] of Object.entries(input)) url.searchParams.set(name, String(value));
|
|
156
|
+
}
|
|
157
|
+
const interrupted = new Promise((_resolve, reject) => {
|
|
158
|
+
const abort = code => {
|
|
159
|
+
interruption ||= code;
|
|
160
|
+
request.abort();
|
|
161
|
+
reject(new ExecutionError(interruption));
|
|
162
|
+
};
|
|
163
|
+
cancel = () => abort("cancelled");
|
|
164
|
+
signal?.addEventListener("abort", cancel, { once: true });
|
|
165
|
+
timeout = setTimeout(() => abort("timeout"), definition.timeoutMs ?? timeoutMs);
|
|
166
|
+
});
|
|
167
|
+
const transfer = async () => {
|
|
168
|
+
dispatched = true;
|
|
169
|
+
// Exactly one fetch per dispatch. Browsers/transports can still replay a
|
|
170
|
+
// POST internally; duplicate business effects must be prevented by the app.
|
|
171
|
+
const response = await fetch(url.href, options);
|
|
172
|
+
if (Number.isInteger(response.status) && response.status >= 100 && response.status <= 599) httpStatus = response.status;
|
|
173
|
+
// Chrome exposes blocked redirects as opaque responses (no visible status).
|
|
174
|
+
if (response.type === "opaqueredirect" || response.redirected ||
|
|
175
|
+
(response.status >= 300 && response.status < 400)) throw new ExecutionError("redirect_blocked");
|
|
176
|
+
if (httpStatus === null) throw new ExecutionError("request_failed");
|
|
177
|
+
if (request.signal.aborted) throw new ExecutionError(interruption);
|
|
178
|
+
if (httpStatus === 204) return result("completed", httpStatus);
|
|
179
|
+
const mediaType = (response.headers.get("Content-Type") || "").split(";", 1)[0].trim().toLowerCase();
|
|
180
|
+
if (mediaType !== "application/json" && !/^application\/[a-z0-9.+-]+\+json$/.test(mediaType)) {
|
|
181
|
+
throw new ExecutionError("non_json_response");
|
|
182
|
+
}
|
|
183
|
+
let data;
|
|
184
|
+
try { data = await response.json(); }
|
|
185
|
+
catch (error) {
|
|
186
|
+
if (request.signal.aborted) throw new ExecutionError(interruption);
|
|
187
|
+
throw new ExecutionError(error instanceof SyntaxError ? "invalid_json" : "request_failed");
|
|
188
|
+
}
|
|
189
|
+
if (request.signal.aborted) throw new ExecutionError(interruption);
|
|
190
|
+
if (!response.ok) return result("failed", httpStatus, data, "http_error");
|
|
191
|
+
return result(httpStatus === 202 ? "accepted" : "completed", httpStatus, data);
|
|
192
|
+
};
|
|
193
|
+
// Includes body transfer, not merely receipt of the HTTP response headers.
|
|
194
|
+
return await Promise.race([transfer(), interrupted]);
|
|
195
|
+
} catch (error) {
|
|
196
|
+
const code = interruption || (error instanceof ExecutionError ? error.code : "request_failed");
|
|
197
|
+
return result(incompleteStatus(), httpStatus, null, code);
|
|
198
|
+
} finally {
|
|
199
|
+
clearTimeout(timeout);
|
|
200
|
+
if (cancel) signal?.removeEventListener("abort", cancel);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function createRuntime({ document, location, fetch, adapter = new WebMCPAdapter(document),
|
|
205
|
+
logger = console, timeoutMs = 30000 }) {
|
|
206
|
+
if (!validTimeout(timeoutMs)) throw new TypeError("timeoutMs must be an integer from 1 to 2147483647");
|
|
207
|
+
const owned = new Map();
|
|
208
|
+
let generation = 0;
|
|
209
|
+
let queue = Promise.resolve();
|
|
210
|
+
const warn = code => logger.warn(`[ActiveWebMCP] ${code}`);
|
|
211
|
+
const release = () => {
|
|
212
|
+
for (const tool of owned.values()) tool.dispose();
|
|
213
|
+
owned.clear();
|
|
214
|
+
};
|
|
215
|
+
const dispose = () => { generation++; release(); };
|
|
216
|
+
|
|
217
|
+
function refresh() {
|
|
218
|
+
const revision = ++generation;
|
|
219
|
+
queue = queue.then(async () => {
|
|
220
|
+
if (revision !== generation) return;
|
|
221
|
+
let selected;
|
|
222
|
+
try {
|
|
223
|
+
if (!adapter.available()) { release(); return; }
|
|
224
|
+
selected = new Map();
|
|
225
|
+
for (const block of document.querySelectorAll('script[type="application/json"][data-active-webmcp]')) {
|
|
226
|
+
const definitions = JSON.parse(block.textContent);
|
|
227
|
+
if (!Array.isArray(definitions)) throw new ExecutionError("invalid_manifest");
|
|
228
|
+
for (const definition of definitions) {
|
|
229
|
+
validateDefinition(definition, location);
|
|
230
|
+
const fingerprint = JSON.stringify(definition);
|
|
231
|
+
if (selected.has(definition.name) && selected.get(definition.name).fingerprint !== fingerprint) {
|
|
232
|
+
throw new ExecutionError("conflicting_page_tools");
|
|
233
|
+
}
|
|
234
|
+
selected.set(definition.name, { definition, fingerprint });
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
} catch {
|
|
238
|
+
release(); warn("invalid_page_manifest"); return;
|
|
239
|
+
}
|
|
240
|
+
for (const [name, tool] of owned) {
|
|
241
|
+
if (selected.get(name)?.fingerprint !== tool.fingerprint) {
|
|
242
|
+
tool.dispose(); owned.delete(name);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
for (const [name, { definition, fingerprint }] of selected) {
|
|
246
|
+
if (revision !== generation) return;
|
|
247
|
+
if (owned.has(name)) continue;
|
|
248
|
+
try {
|
|
249
|
+
const cleanup = await adapter.register(definition, (input, { signal } = {}) =>
|
|
250
|
+
execute(definition, input, signal, { document, fetch, location, timeoutMs }));
|
|
251
|
+
if (revision !== generation) { cleanup(); return; }
|
|
252
|
+
owned.set(name, { fingerprint, dispose: cleanup });
|
|
253
|
+
} catch {
|
|
254
|
+
// registerTool rejects a collision; never remove or adopt the other owner.
|
|
255
|
+
warn("registration_failed");
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
return queue;
|
|
260
|
+
}
|
|
261
|
+
return { refresh, dispose };
|
|
262
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { createRuntime } from "active_webmcp/runtime";
|
|
2
|
+
|
|
3
|
+
// One owner per document, even if an entrypoint is initialized more than once.
|
|
4
|
+
const key = Symbol.for("active_webmcp.runtime");
|
|
5
|
+
if (!globalThis[key]) {
|
|
6
|
+
const runtime = createRuntime({ document, location, fetch: globalThis.fetch.bind(globalThis) });
|
|
7
|
+
globalThis[key] = runtime;
|
|
8
|
+
document.addEventListener("DOMContentLoaded", () => runtime.refresh());
|
|
9
|
+
window.addEventListener("pageshow", () => runtime.refresh());
|
|
10
|
+
window.addEventListener("pagehide", () => runtime.dispose());
|
|
11
|
+
// Turbo Drive replaces the body without replacing this document's runtime.
|
|
12
|
+
// Keep previews inactive; reconcile on the completed visit (including cached
|
|
13
|
+
// restoration). Frames, Streams and custom routers are not supported here.
|
|
14
|
+
document.addEventListener("turbo:before-cache", () => runtime.dispose());
|
|
15
|
+
document.addEventListener("turbo:before-render", () => runtime.dispose());
|
|
16
|
+
document.addEventListener("turbo:load", () => runtime.refresh());
|
|
17
|
+
}
|
|
18
|
+
const runtime = globalThis[key];
|
|
19
|
+
export const refresh = () => runtime.refresh();
|
|
20
|
+
export const dispose = () => runtime.dispose();
|
|
21
|
+
refresh();
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveWebMCP
|
|
4
|
+
# Adds immutable WebMCP tool declarations to Rails controllers.
|
|
5
|
+
module Controller
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
included do
|
|
9
|
+
class_attribute :webmcp_definitions, instance_writer: false, default: {}.freeze
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
class_methods do
|
|
13
|
+
def webmcp_tool(action, **options)
|
|
14
|
+
definition = ToolDefinition.new(action, **options)
|
|
15
|
+
if webmcp_definitions.key?(definition.name)
|
|
16
|
+
raise ConfigurationError, "tool already declared (possibly inherited): #{definition.name}"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
self.webmcp_definitions = webmcp_definitions.merge(definition.name => definition).freeze
|
|
20
|
+
definition
|
|
21
|
+
rescue ArgumentError => e
|
|
22
|
+
raise e if e.is_a?(ConfigurationError)
|
|
23
|
+
|
|
24
|
+
raise ConfigurationError, "invalid webmcp_tool declaration: #{e.message}"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveWebMCP
|
|
4
|
+
# Hooks ActiveWebMCP into Rails controllers and views.
|
|
5
|
+
class Engine < ::Rails::Engine
|
|
6
|
+
config.active_webmcp = ActiveSupport::OrderedOptions.new
|
|
7
|
+
config.active_webmcp.timeout_ms = 30_000
|
|
8
|
+
|
|
9
|
+
initializer "active_webmcp.controller" do
|
|
10
|
+
ActiveSupport.on_load(:action_controller_base) { include ActiveWebMCP::Controller }
|
|
11
|
+
ActiveSupport.on_load(:action_view) { include ActiveWebMCP::PageHelper }
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveWebMCP
|
|
4
|
+
# Renders selected controller tools as inert JSON metadata.
|
|
5
|
+
module PageHelper
|
|
6
|
+
def webmcp_tools(*names, controller: self.controller.class)
|
|
7
|
+
validate_controller!(controller)
|
|
8
|
+
timeout = configured_timeout
|
|
9
|
+
manifests = names.map(&:to_s).uniq.map { |name| manifest_for(controller, name, timeout) }
|
|
10
|
+
# JSON escaping prevents </script>, HTML and Unicode separators from escaping
|
|
11
|
+
# this inert data block. No user/request data is retained on controller classes.
|
|
12
|
+
tag.script(ERB::Util.json_escape(manifests.to_json).html_safe,
|
|
13
|
+
type: "application/json", data: { active_webmcp: true })
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def validate_controller!(controller)
|
|
19
|
+
valid = controller.is_a?(Class) && controller <= ActionController::Base
|
|
20
|
+
valid &&= controller.respond_to?(:webmcp_definitions)
|
|
21
|
+
raise ConfigurationError, "controller: must be a Rails controller class" unless valid
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def configured_timeout
|
|
25
|
+
timeout = Rails.application.config.active_webmcp.timeout_ms
|
|
26
|
+
return timeout if timeout.is_a?(Integer) && timeout.between?(1, 2_147_483_647)
|
|
27
|
+
|
|
28
|
+
raise ConfigurationError, "config.active_webmcp.timeout_ms must be an integer from 1 to 2147483647"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def manifest_for(controller, name, timeout)
|
|
32
|
+
definition = controller.webmcp_definitions.fetch(name) do
|
|
33
|
+
raise ConfigurationError, "tool not declared on #{controller.name}: #{name}"
|
|
34
|
+
end
|
|
35
|
+
manifest = { name: definition.name, description: definition.description, inputSchema: definition.schema,
|
|
36
|
+
timeoutMs: timeout,
|
|
37
|
+
endpoint: { path: active_webmcp_path(controller, definition), method: definition.method } }
|
|
38
|
+
manifest[:title] = definition.title if definition.title
|
|
39
|
+
manifest[:annotations] = definition.annotations if definition.annotations
|
|
40
|
+
manifest
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def active_webmcp_path(controller_class, definition)
|
|
44
|
+
routes = Rails.application.routes
|
|
45
|
+
route = declared_route!(routes, definition)
|
|
46
|
+
validate_route!(route, controller_class, definition)
|
|
47
|
+
path = public_send("#{definition.route}_path")
|
|
48
|
+
validate_generated_path!(path, definition)
|
|
49
|
+
validate_recognition!(routes, path, controller_class, definition)
|
|
50
|
+
path
|
|
51
|
+
rescue ActionController::UrlGenerationError, ActionController::RoutingError
|
|
52
|
+
raise ConfigurationError, "could not resolve the declared local endpoint: #{definition.route}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def declared_route!(routes, definition)
|
|
56
|
+
routes.named_routes.get(definition.route.to_sym) ||
|
|
57
|
+
raise(ConfigurationError, "named route not found: #{definition.route}")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def validate_route!(route, controller, definition)
|
|
61
|
+
validate_public_action!(controller, definition)
|
|
62
|
+
validate_route_target!(route, controller, definition)
|
|
63
|
+
validate_static_route!(route, definition)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def validate_public_action!(controller, definition)
|
|
67
|
+
return if controller.action_methods.include?(definition.action)
|
|
68
|
+
|
|
69
|
+
raise ConfigurationError, "tool action is not public: #{definition.action}"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def validate_route_target!(route, controller, definition)
|
|
73
|
+
target = [controller.controller_path, definition.action]
|
|
74
|
+
return if route.defaults.values_at(:controller, :action) == target && route.verb == definition.method
|
|
75
|
+
|
|
76
|
+
raise ConfigurationError,
|
|
77
|
+
"named route does not match the declared controller, action and method: #{definition.route}"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def validate_static_route!(route, definition)
|
|
81
|
+
return if (route.parts - [:format]).empty? && !route.path.spec.to_s.include?("*")
|
|
82
|
+
|
|
83
|
+
raise ConfigurationError, "dynamic path segments and wildcard routes are not supported: #{definition.route}"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def validate_generated_path!(path, definition)
|
|
87
|
+
return if path.start_with?("/") && !path.start_with?("//") && !path.match?(/[\\?#\x00-\x20]/)
|
|
88
|
+
|
|
89
|
+
raise ConfigurationError, "route must generate a local path without query or fragment: #{definition.route}"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def validate_recognition!(routes, path, controller, definition)
|
|
93
|
+
recognized = routes.recognize_path(path, method: definition.method)
|
|
94
|
+
return if recognized.values_at(:controller, :action) == [controller.controller_path, definition.action]
|
|
95
|
+
|
|
96
|
+
raise ConfigurationError, "named route is shadowed by another endpoint: #{definition.route}"
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveWebMCP
|
|
4
|
+
# Compiles the supported flat parameter declarations into JSON Schema.
|
|
5
|
+
class SchemaCompiler
|
|
6
|
+
TYPES = %w[string integer number boolean].freeze
|
|
7
|
+
OPTIONS = %w[type required description enum].freeze
|
|
8
|
+
RESERVED = %w[controller action format host protocol port url origin path method script_name
|
|
9
|
+
authenticity_token _method utf8 __proto__ prototype constructor].freeze
|
|
10
|
+
SAFE_INTEGER = (2**53) - 1
|
|
11
|
+
|
|
12
|
+
def self.compile(parameters)
|
|
13
|
+
raise ConfigurationError, "parameters must be a Hash" unless parameters.is_a?(Hash)
|
|
14
|
+
|
|
15
|
+
properties = {}
|
|
16
|
+
required = []
|
|
17
|
+
parameters.each do |key, declaration|
|
|
18
|
+
name, property, required_parameter = compile_parameter(key, declaration)
|
|
19
|
+
raise ConfigurationError, "duplicate parameter: #{name}" if properties.key?(name)
|
|
20
|
+
|
|
21
|
+
properties[name] = property
|
|
22
|
+
required << name if required_parameter
|
|
23
|
+
end
|
|
24
|
+
ActiveWebMCP.deep_freeze({ "type" => "object", "properties" => properties,
|
|
25
|
+
"required" => required, "additionalProperties" => false })
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.compile_parameter(key, declaration)
|
|
29
|
+
name = parameter_name(key)
|
|
30
|
+
options = parameter_options(name, declaration)
|
|
31
|
+
type = parameter_type(name, options)
|
|
32
|
+
validate_required!(name, options)
|
|
33
|
+
[name, property_schema(name, type, options), options["required"]]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.parameter_name(key)
|
|
37
|
+
unless key.is_a?(String) || key.is_a?(Symbol)
|
|
38
|
+
raise ConfigurationError,
|
|
39
|
+
"parameter names must be strings or symbols"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
name = key.to_s
|
|
43
|
+
return name if name.match?(/\A[a-zA-Z_][a-zA-Z0-9_]*\z/) && !RESERVED.include?(name)
|
|
44
|
+
|
|
45
|
+
raise ConfigurationError, "unsupported or reserved parameter name: #{name}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.parameter_options(name, declaration)
|
|
49
|
+
raise ConfigurationError, "parameter #{name} must have a declaration Hash" unless declaration.is_a?(Hash)
|
|
50
|
+
|
|
51
|
+
options = declaration.transform_keys(&:to_s)
|
|
52
|
+
return options if options.size == declaration.size && (options.keys - OPTIONS).empty?
|
|
53
|
+
|
|
54
|
+
raise ConfigurationError, "unsupported or duplicate options for parameter #{name}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.parameter_type(name, options)
|
|
58
|
+
type = options["type"].to_s.dup
|
|
59
|
+
return type if TYPES.include?(type)
|
|
60
|
+
|
|
61
|
+
raise ConfigurationError, "unsupported type for parameter #{name}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def self.validate_required!(name, options)
|
|
65
|
+
return unless options.key?("required") && ![true, false].include?(options["required"])
|
|
66
|
+
|
|
67
|
+
raise ConfigurationError, "required must be a boolean for parameter #{name}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def self.property_schema(name, type, options)
|
|
71
|
+
property = { "type" => type }
|
|
72
|
+
property["description"] = description(name, options) if options.key?("description")
|
|
73
|
+
property["enum"] = enum_values(name, type, options) if options.key?("enum")
|
|
74
|
+
property
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def self.description(name, options)
|
|
78
|
+
value = options["description"]
|
|
79
|
+
raise ConfigurationError, "description must be a string for parameter #{name}" unless value.is_a?(String)
|
|
80
|
+
|
|
81
|
+
value.dup
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def self.enum_values(name, type, options)
|
|
85
|
+
values = options["enum"]
|
|
86
|
+
valid = values.is_a?(Array) && !values.empty? && values.all? { |value| valid_value?(type, value) }
|
|
87
|
+
raise ConfigurationError, "enum must contain values matching the type for parameter #{name}" unless valid
|
|
88
|
+
|
|
89
|
+
values.deep_dup.uniq
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def self.valid_value?(type, value)
|
|
93
|
+
case type
|
|
94
|
+
when "string" then value.is_a?(String)
|
|
95
|
+
when "boolean" then [true, false].include?(value)
|
|
96
|
+
when "integer" then value.is_a?(Integer) && value.abs <= SAFE_INTEGER
|
|
97
|
+
when "number" then valid_number?(value)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def self.valid_number?(value)
|
|
102
|
+
(value.is_a?(Integer) || value.is_a?(Float)) && value.finite? &&
|
|
103
|
+
(!value.is_a?(Integer) || value.abs <= SAFE_INTEGER)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveWebMCP
|
|
4
|
+
# Immutable, validated declaration of one controller-backed browser tool.
|
|
5
|
+
class ToolDefinition
|
|
6
|
+
attr_reader :action, :name, :title, :description, :route, :method, :schema, :annotations
|
|
7
|
+
|
|
8
|
+
def initialize(action, name:, description:, route:, method:, execution: :json, parameters: {}, title: nil,
|
|
9
|
+
read_only_hint: false, untrusted_content_hint: false)
|
|
10
|
+
validate_identifier_types!(action, name, route)
|
|
11
|
+
assign_identifiers(action, name, route, method)
|
|
12
|
+
validate_identifiers!
|
|
13
|
+
validate_options!(description, execution, title, read_only_hint, untrusted_content_hint)
|
|
14
|
+
@description = description.dup.freeze
|
|
15
|
+
@title = title&.dup&.freeze
|
|
16
|
+
@annotations = compile_annotations(read_only_hint, untrusted_content_hint)
|
|
17
|
+
@schema = SchemaCompiler.compile(parameters)
|
|
18
|
+
freeze
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def assign_identifiers(action, name, route, method)
|
|
24
|
+
@action = action.to_s.dup.freeze
|
|
25
|
+
@name = name.to_s.dup.freeze
|
|
26
|
+
@route = route.to_s.dup.freeze
|
|
27
|
+
@method = method.to_s.upcase.freeze
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def validate_identifier_types!(*values)
|
|
31
|
+
return if values.all? { |value| value.is_a?(String) || value.is_a?(Symbol) }
|
|
32
|
+
|
|
33
|
+
raise ConfigurationError, "action, name and route must be strings or symbols"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def validate_identifiers!
|
|
37
|
+
unless @action.match?(/\A[a-zA-Z_][a-zA-Z0-9_]*\z/) && @route.match?(/\A[a-zA-Z_][a-zA-Z0-9_]*\z/)
|
|
38
|
+
raise ConfigurationError, "action and named route must be identifiers"
|
|
39
|
+
end
|
|
40
|
+
return if @name.match?(/\A[a-zA-Z0-9_.-]{1,128}\z/)
|
|
41
|
+
|
|
42
|
+
raise ConfigurationError, "tool name must contain 1–128 ASCII letters, digits, dots, underscores or hyphens"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def validate_options!(description, execution, title, *hints)
|
|
46
|
+
validate_description!(description)
|
|
47
|
+
validate_title!(title)
|
|
48
|
+
validate_hints!(hints)
|
|
49
|
+
raise ConfigurationError, "method must be :get or :post" unless %w[GET POST].include?(@method)
|
|
50
|
+
raise ConfigurationError, "only execution: :json is supported" unless execution.to_s == "json"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def validate_description!(description)
|
|
54
|
+
return if description.is_a?(String) && !description.strip.empty?
|
|
55
|
+
|
|
56
|
+
raise ConfigurationError, "tool description must be a nonempty string"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def validate_title!(title)
|
|
60
|
+
return if title.nil? || (title.is_a?(String) && !title.strip.empty?)
|
|
61
|
+
|
|
62
|
+
raise ConfigurationError, "tool title must be nil or a nonempty string"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def validate_hints!(hints)
|
|
66
|
+
return if hints.all? { |hint| [true, false].include?(hint) }
|
|
67
|
+
|
|
68
|
+
raise ConfigurationError, "tool annotation hints must be true or false"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def compile_annotations(read_only_hint, untrusted_content_hint)
|
|
72
|
+
return if !read_only_hint && !untrusted_content_hint
|
|
73
|
+
|
|
74
|
+
{ readOnlyHint: read_only_hint, untrustedContentHint: untrusted_content_hint }.freeze
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/concern"
|
|
4
|
+
require "active_support/core_ext/class/attribute"
|
|
5
|
+
require "active_support/core_ext/object/deep_dup"
|
|
6
|
+
require "rails/engine"
|
|
7
|
+
require_relative "active_webmcp/version"
|
|
8
|
+
|
|
9
|
+
# Controller-defined WebMCP tools for Rails applications.
|
|
10
|
+
module ActiveWebMCP
|
|
11
|
+
class ConfigurationError < ArgumentError; end
|
|
12
|
+
|
|
13
|
+
def self.deep_freeze(value)
|
|
14
|
+
case value
|
|
15
|
+
when Hash
|
|
16
|
+
value.each do |key, item|
|
|
17
|
+
deep_freeze(key)
|
|
18
|
+
deep_freeze(item)
|
|
19
|
+
end
|
|
20
|
+
when Array
|
|
21
|
+
value.each { |item| deep_freeze(item) }
|
|
22
|
+
end
|
|
23
|
+
value.freeze
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
require_relative "active_webmcp/schema_compiler"
|
|
28
|
+
require_relative "active_webmcp/tool_definition"
|
|
29
|
+
require_relative "active_webmcp/controller"
|
|
30
|
+
require_relative "active_webmcp/page_helper"
|
|
31
|
+
require_relative "active_webmcp/engine"
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
|
|
5
|
+
module ActiveWebMCP
|
|
6
|
+
module Generators
|
|
7
|
+
# Installs the importmap pins and JavaScript entrypoint into a Rails app.
|
|
8
|
+
class InstallGenerator < Rails::Generators::Base
|
|
9
|
+
namespace "active_webmcp:install"
|
|
10
|
+
desc "Install ActiveWebMCP for Rails 8.1 with importmap and Propshaft."
|
|
11
|
+
|
|
12
|
+
PINS = { "active_webmcp" => "active_webmcp.js", "active_webmcp/runtime" => "active_webmcp/runtime.js" }.freeze
|
|
13
|
+
|
|
14
|
+
def validate_setup
|
|
15
|
+
validate_dependencies!
|
|
16
|
+
importmap_path = destination_path("config/importmap.rb")
|
|
17
|
+
validate_application_files!(importmap_path)
|
|
18
|
+
@missing_pins = missing_pins(File.read(importmap_path))
|
|
19
|
+
detect_application_import
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def validate_dependencies!
|
|
25
|
+
return if defined?(Propshaft) && defined?(Importmap) && Rails.version.start_with?("8.1.")
|
|
26
|
+
|
|
27
|
+
raise Rails::Generators::Error, "ActiveWebMCP requires Rails 8.1, importmap-rails and Propshaft."
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def validate_application_files!(importmap_path)
|
|
31
|
+
layout_path = destination_path("app/views/layouts/application.html.erb")
|
|
32
|
+
valid_layout = File.file?(layout_path) && File.read(layout_path).include?("javascript_importmap_tags")
|
|
33
|
+
return if File.file?(importmap_path) && valid_layout
|
|
34
|
+
|
|
35
|
+
raise Rails::Generators::Error,
|
|
36
|
+
"Install importmap first; the application layout must use javascript_importmap_tags."
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def missing_pins(source)
|
|
40
|
+
PINS.reject { |name, asset| existing_pin?(source, name, asset) }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def existing_pin?(source, name, asset)
|
|
44
|
+
lines = source.lines.grep_v(/^\s*#/).grep(/["']#{Regexp.escape(name)}["']/)
|
|
45
|
+
return false if lines.empty?
|
|
46
|
+
|
|
47
|
+
expected = /^\s*pin\s+["']#{Regexp.escape(name)}["']\s*,\s*to:\s*["']#{Regexp.escape(asset)}["']\s*(?:#.*)?$/
|
|
48
|
+
return true if lines.one? && lines.first.match?(expected)
|
|
49
|
+
|
|
50
|
+
raise Rails::Generators::Error, "Conflicting importmap pin for #{name}; resolve it before installing."
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def detect_application_import
|
|
54
|
+
application_path = destination_path("app/javascript/application.js")
|
|
55
|
+
@application_exists = File.file?(application_path)
|
|
56
|
+
@import_exists = @application_exists && File.read(application_path)
|
|
57
|
+
.match?(%r{^\s*import\s+["']active_webmcp["']\s*;?\s*(?://.*)?$})
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def destination_path(relative_path)
|
|
61
|
+
File.join(destination_root, relative_path)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
public
|
|
65
|
+
|
|
66
|
+
def install_assets
|
|
67
|
+
@missing_pins.each do |name, asset|
|
|
68
|
+
append_to_file "config/importmap.rb", "\npin #{name.inspect}, to: #{asset.inspect}\n"
|
|
69
|
+
end
|
|
70
|
+
unless @import_exists
|
|
71
|
+
if @application_exists
|
|
72
|
+
append_to_file "app/javascript/application.js", "\nimport \"active_webmcp\";\n"
|
|
73
|
+
else
|
|
74
|
+
create_file "app/javascript/application.js", "import \"active_webmcp\";\n"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
say "ActiveWebMCP installed. Declare a GET or POST tool, then select its public name with webmcp_tools."
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: active_webmcp
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- zbin.song@gmail.com
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: actionpack
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: 8.1.0
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: 8.1.0
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: actionview
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: 8.1.0
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: 8.1.0
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: importmap-rails
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '2.2'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '2.2'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: propshaft
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '1.3'
|
|
61
|
+
type: :runtime
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - "~>"
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '1.3'
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: railties
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - "~>"
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: 8.1.0
|
|
75
|
+
type: :runtime
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - "~>"
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: 8.1.0
|
|
82
|
+
description: An experimental Rails integration for explicitly selected, same-origin
|
|
83
|
+
GET and POST browser tools with normalized JSON results.
|
|
84
|
+
email:
|
|
85
|
+
- zbin.song@gmail.com
|
|
86
|
+
executables: []
|
|
87
|
+
extensions: []
|
|
88
|
+
extra_rdoc_files: []
|
|
89
|
+
files:
|
|
90
|
+
- CHANGELOG.md
|
|
91
|
+
- LICENSE
|
|
92
|
+
- README.md
|
|
93
|
+
- SECURITY.md
|
|
94
|
+
- app/assets/javascripts/active_webmcp.js
|
|
95
|
+
- app/assets/javascripts/active_webmcp/runtime.js
|
|
96
|
+
- lib/active_webmcp.rb
|
|
97
|
+
- lib/active_webmcp/controller.rb
|
|
98
|
+
- lib/active_webmcp/engine.rb
|
|
99
|
+
- lib/active_webmcp/page_helper.rb
|
|
100
|
+
- lib/active_webmcp/schema_compiler.rb
|
|
101
|
+
- lib/active_webmcp/tool_definition.rb
|
|
102
|
+
- lib/active_webmcp/version.rb
|
|
103
|
+
- lib/generators/active_webmcp/install/install_generator.rb
|
|
104
|
+
homepage: https://github.com/benjis/Active_WebMCP
|
|
105
|
+
licenses:
|
|
106
|
+
- MIT
|
|
107
|
+
metadata:
|
|
108
|
+
homepage_uri: https://github.com/benjis/Active_WebMCP
|
|
109
|
+
source_code_uri: https://github.com/benjis/Active_WebMCP/tree/v0.1.0
|
|
110
|
+
changelog_uri: https://github.com/benjis/Active_WebMCP/blob/v0.1.0/CHANGELOG.md
|
|
111
|
+
bug_tracker_uri: https://github.com/benjis/Active_WebMCP/issues
|
|
112
|
+
rubygems_mfa_required: 'true'
|
|
113
|
+
allowed_push_host: https://rubygems.org
|
|
114
|
+
rdoc_options: []
|
|
115
|
+
require_paths:
|
|
116
|
+
- lib
|
|
117
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
118
|
+
requirements:
|
|
119
|
+
- - ">="
|
|
120
|
+
- !ruby/object:Gem::Version
|
|
121
|
+
version: '3.3'
|
|
122
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
123
|
+
requirements:
|
|
124
|
+
- - ">="
|
|
125
|
+
- !ruby/object:Gem::Version
|
|
126
|
+
version: '0'
|
|
127
|
+
requirements: []
|
|
128
|
+
rubygems_version: 4.0.16
|
|
129
|
+
specification_version: 4
|
|
130
|
+
summary: Explicit controller-defined WebMCP tools for Rails pages
|
|
131
|
+
test_files: []
|