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 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
+ [![CI](https://github.com/benjis/Active_WebMCP/actions/workflows/ci.yml/badge.svg)](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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveWebMCP
4
+ VERSION = "0.1.0"
5
+ 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: []