webmcp 0.0.1 → 0.1.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ce68cc934f356e7176b4172ac6df65d9726da7b2075916134c57b529437d7709
4
- data.tar.gz: 4128861c35d56f483c70839a6bb57451bd446c59ade477bbeac7ebb9f6bd6169
3
+ metadata.gz: c32274fd7dc9ffe43c9204439ab0d2b4bb40bfb9720a6a90aa3afdc4ee2b973c
4
+ data.tar.gz: 4801f44a17de9f4ac612eb6fe57dc8b95814f851dc381b3547ba0e63c505584c
5
5
  SHA512:
6
- metadata.gz: 77996d3ef9cbdfc781afc380d747966d7b5747162d46107db6d43b9475c76e2e70f613dd4c99764d44886fd4065130537a57f1086761654e8359898c709e9ca2
7
- data.tar.gz: d869d16246a0d204467252184c8a38c433b497e3116ac172ee45cc8d06fca7481f7b02b92aa88490912f15463beeba1407d55755f9203ca70cfa7b48e266213a
6
+ metadata.gz: 153a2e664db64df9b278b7607d50fd4dafdf854977e2ca4f849545a2b68b13e66c8078c0b45faf43c46c8d4ce78c867c1723b37f521d4adaf3db65a6ed2d584a
7
+ data.tar.gz: a385501050755b4c71afe2e264afeff7a8f71c74e6d603e88ea920a40f88731f71dbb4ef729e6e840a3cdcf68b3f63378ed60db49bb5c63e7620b4dbb218b2e5
data/CHANGELOG.md ADDED
@@ -0,0 +1,30 @@
1
+ # Changelog
2
+
3
+ ## 0.1.1 — 2026-10-06
4
+
5
+ - Documentation for people and AI agents: `AGENTS.md`, `llms.txt`, a README troubleshooting table with exact error messages, and changelog/issue/documentation links in the gemspec.
6
+
7
+ ## 0.1.0
8
+
9
+ - Autostart opted-in manifests from the external runtime under strict CSP, expose
10
+ `WebMCPRuntime.handle`, and emit `webmcp:mounted`; support `autostart: false`.
11
+ - Add explicit Rails 8/Turbo/Chrome integration and built-gem production asset
12
+ smoke tasks. All 9 browser checks pass on Chrome 154.0.8037.98 with Rails 8.1
13
+ and 8.0. Chrome 154 accepts only legacy JSON-string `executeTool` input (object
14
+ input ships in 155), so the agent-side test helper falls back to it;
15
+ `WEBMCP_STRICT_OBJECT_INPUT=1` runs the object-only gate.
16
+
17
+ - Add immutable validated tool definitions and explicit MCP metadata projections,
18
+ source drift checks, and canonical effective-contract fingerprints.
19
+ - Add a boot-time registry, manifest v1 rendering, safe JSON script embedding and
20
+ language-neutral conformance fixtures.
21
+ - Add optional Rails 7.1+ page opt-in helpers, declarative form attributes that
22
+ preserve custom builders, CSP nonce handling, and runtime asset synchronization.
23
+ - Add standalone Rack Origin-Trial middleware and escaped meta tags, preserving
24
+ existing headers and warning once about legacy OAC opt-out behavior.
25
+ - Pin the implementation contract to Draft CG Report 2026-10-02; browser and
26
+ production-asset release gates remain separate from Ruby unit verification.
27
+
28
+ ## 0.0.1
29
+
30
+ - Initial gem skeleton.
data/README.md CHANGED
@@ -1,22 +1,394 @@
1
1
  # webmcp
2
2
 
3
- Ruby/Rails toolkit for [WebMCP](https://github.com/webmachinelearning/webmcp) —
4
- the W3C proposal that lets web pages declare structured tools for AI agents
5
- (`document.modelContext`).
3
+ [![Gem Version](https://img.shields.io/gem/v/webmcp)](https://rubygems.org/gems/webmcp) [![CI](https://github.com/seunghan91/webmcp/actions/workflows/ci.yml/badge.svg)](https://github.com/seunghan91/webmcp/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE.txt)
6
4
 
7
- **Status: early development.** The WebMCP spec is in Chrome origin trial
8
- (Chrome 149–156) and its API surface has already changed twice. This gem is
9
- being extracted from a production integration and will ship its first usable
10
- release once the spec surface stabilizes.
5
+ Server-side WebMCP toolkit for Ruby and Rails — the reference implementation of the family.
11
6
 
12
- Planned:
7
+ [WebMCP](https://github.com/webmachinelearning/webmcp) is a W3C Community Group
8
+ proposal that lets a web page register tools an in-browser AI agent can call
9
+ through `document.modelContext`. This gem is the server side of that: you define
10
+ tools where your app already knows its routes, sessions and permissions, and a
11
+ small browser runtime registers them on the pages you choose. When an agent calls
12
+ a tool, the runtime calls your existing same-origin endpoint with the user's
13
+ session and CSRF token, so authentication and authorization stay in your app.
13
14
 
14
- - Define tools once in Ruby; emit both server-side MCP schemas and WebMCP
15
- registration payloads
16
- - Declarative helpers: `form_with ..., webmcp:` → `toolname` /
17
- `tooldescription` / `toolparamdescription` attributes
18
- - Rack middleware for the `Origin-Trial` token header
15
+ - **Tool definitions** — `WebMCP::Tool.define`, validated against the spec's naming, annotation and schema rules at boot.
16
+ - **Projection from MCP SDK tools** — `WebMCP::Tool.from_mcp` reuses a tool's identity from the official [`mcp`](https://rubygems.org/gems/mcp) gem and makes every browser-side difference explicit, with a pinned source fingerprint that fails tests when the MCP tool drifts.
17
+ - **Rails helpers** — `webmcp_manifest_tag` (per-page opt-in), `webmcp_runtime_tag` (CSP nonce aware, autostart), `form_with ..., webmcp: {...}` for declarative forms (keeps custom FormBuilders).
18
+ - **Origin Trial** — `WebMCP::OriginTrial` Rack middleware and `webmcp_origin_trial_meta_tag`.
19
+ - **Shared browser runtime** — zero dependencies; same-origin only, `redirect: 'error'`, CSRF read per call, declared parameters only, read/write outcome envelopes, no retries.
19
20
 
20
- ## License
21
+ ```ruby
22
+ # Gemfile
23
+ gem "webmcp", "~> 0.1"
24
+ ```
21
25
 
22
- MIT
26
+ **Status:** 0.x, tracking the WebMCP Draft CG Report of 2026-10-02. WebMCP runs
27
+ behind a Chrome origin trial (Chrome 149–156, extension requested to 162) or the
28
+ `chrome://flags/#enable-webmcp-testing` flag. The shared runtime is tested in real
29
+ Chrome 154 by the [Ruby reference suite](https://github.com/seunghan91/webmcp/blob/main/test/integration/RESULTS.md)
30
+ (CSRF-protected writes, blocked redirects, HTTP errors, Turbo navigation, strict CSP).
31
+
32
+ | Language | Package | Registry |
33
+ |---|---|---|
34
+ | Ruby / Rails (reference) | [`webmcp`](https://github.com/seunghan91/webmcp) | [RubyGems](https://rubygems.org/gems/webmcp) |
35
+ | Go (`net/http`) | [`webmcp-go`](https://github.com/seunghan91/webmcp-go) | [pkg.go.dev](https://pkg.go.dev/github.com/seunghan91/webmcp-go) |
36
+ | Python / Django | [`webmcp-django`](https://github.com/seunghan91/webmcp-django) | [PyPI](https://pypi.org/project/webmcp-django/) |
37
+ | Rust | [`webmcp`](https://github.com/seunghan91/webmcp-rust) | [crates.io](https://crates.io/crates/webmcp) |
38
+
39
+ All four emit the same manifest v1 (checked against shared conformance fixtures,
40
+ fingerprints included) and ship the byte-identical browser runtime.
41
+
42
+ Coding agents: start with [AGENTS.md](AGENTS.md). LLM summary: [llms.txt](llms.txt).
43
+
44
+ ## Intent: share identity, project the rest explicitly
45
+
46
+ **Surfaces are intentionally different; share identity, project the rest explicitly.**
47
+ A server MCP tool and its browser counterpart can represent the same feature
48
+ while intentionally having different schemas, limits, and execution paths.
49
+
50
+ | Difference | Server MCP | Browser WebMCP | Reason |
51
+ |---|---|---|---|
52
+ | Field names | `content`, `id` | `title`, `task_id` | Browser agents benefit from names matching visible labels. |
53
+ | Dates | ISO 8601 | `YYYY-MM-DD HH:MM` in the user's timezone | Match what the user sees. |
54
+ | Result limit | 500 | 20 | Keep results useful within the browser's response budget and tab context. |
55
+ | Execution path | Service object → database | Session cookies → existing web endpoint | Preserve session, CSRF and authorization checks. |
56
+
57
+ Identity and descriptions can start from one source. Schema changes, annotations,
58
+ limits and endpoints are explicit projections. MCP and WebMCP annotations are
59
+ different sets: `destructiveHint` does not automatically mean `consequentialHint`.
60
+ Even `readOnlyHint` must be declared again for the browser endpoint.
61
+
62
+ ## Quick start
63
+
64
+ Ruby >= 3.1; no runtime gem dependencies. Rails >= 7.1 and the `mcp` gem >= 1.1
65
+ are optional. Add `gem "webmcp", "~> 0.1.0"` to your Gemfile.
66
+
67
+ ```ruby
68
+ require "webmcp"
69
+
70
+ ListTasks = WebMCP::Tool.define(
71
+ name: "list_tasks",
72
+ title: "List tasks",
73
+ description: "List at most 20 tasks for the signed-in user.",
74
+ input_schema: {
75
+ type: "object",
76
+ properties: {
77
+ tags: { type: "array", items: { type: "string" } },
78
+ completed: { type: "boolean" }
79
+ }
80
+ },
81
+ annotations: { read_only: true, untrusted_content: true },
82
+ endpoint: { path: "/api/tasks", method: :get },
83
+ max_response_chars: 1500
84
+ )
85
+ WebMCP.register(ListTasks)
86
+ ```
87
+
88
+ Definitions are deeply frozen copies. Duplicate names fail. Rails freezes the
89
+ registry after initialization; in a Rack application call `WebMCP.freeze!` after
90
+ registering tools at boot. `WebMCP.tools` returns a frozen list.
91
+
92
+ ### Project an existing MCP tool
93
+
94
+ First obtain the source fingerprint in a console:
95
+
96
+ ```ruby
97
+ WebMCP::Testing.current_source_fingerprint(McpTools::ListTasks)
98
+ # => "sha256:..."
99
+ ```
100
+
101
+ Paste that literal into the checked-in definition. Do not compute it dynamically
102
+ at boot: that would erase the drift baseline.
103
+
104
+ ```ruby
105
+ ListTasksBrowser = WebMCP::Tool.from_mcp(
106
+ McpTools::ListTasks,
107
+ source_fingerprint: "sha256:PASTE_THE_REVIEWED_SOURCE_FINGERPRINT_HERE"
108
+ ) do
109
+ # For a source already in the 0.x subset, rename only top-level properties.
110
+ rename_params content: :title, id: :task_id
111
+ input_schema do |schema| # String keys; already renamed, including required.
112
+ schema["properties"]["limit"]["maximum"] = 20
113
+ schema
114
+ end
115
+ annotations read_only: true, untrusted_content: true
116
+ endpoint path: "/api/tasks", method: :get, param_map: { title: :content }
117
+ max_response_chars 1500
118
+ end
119
+ WebMCP.register(ListTasksBrowser)
120
+ WebMCP::Testing.assert_projection_fresh(ListTasksBrowser)
121
+ ```
122
+
123
+ Missing or stale fingerprints raise `WebMCP::DefinitionError` with the current
124
+ value. Tests can call `assert_projection_fresh` to detect later source changes.
125
+ `projection_notes` describes renames and overrides for debugging and never
126
+ appears in the manifest. Override `name`, `title`, `description` or
127
+ `max_response_chars` in the block as needed. No MCP annotations are inherited;
128
+ even a write projection must call `annotations` explicitly (an empty call is valid).
129
+
130
+ The official MCP SDK adds a `$schema` dialect declaration. That keyword is outside
131
+ this toolkit's deliberately strict 0.x subset. For an SDK schema, use an explicit
132
+ schema projection; do not use `rename_params` on an out-of-subset source:
133
+
134
+ ```ruby
135
+ # Source can be an MCP::Tool subclass or the class returned by MCP::Tool.define.
136
+ BrowserSearch = WebMCP::Tool.from_mcp(
137
+ McpTools::Search,
138
+ source_fingerprint: "sha256:PASTE_THE_REVIEWED_SOURCE_FINGERPRINT_HERE"
139
+ ) do
140
+ input_schema do |schema|
141
+ schema = schema.reject { |key, _| key == "$schema" }
142
+ # If needed, explicitly replace the schema here, including required names.
143
+ schema
144
+ end
145
+ annotations read_only: true
146
+ endpoint path: "/api/search", method: :post
147
+ end
148
+ ```
149
+
150
+ The bridge duck-types `to_h`; it never requires the MCP SDK or invokes its tools.
151
+ The source fingerprint includes the original schema, including `$schema`.
152
+
153
+ ### Rails views: opt in on each page
154
+
155
+ ```erb
156
+ <%= csrf_meta_tags %>
157
+ <%= webmcp_manifest_tag(:list_tasks) %>
158
+ <%= webmcp_runtime_tag %>
159
+ ```
160
+
161
+ Only listed tools are exposed. With no names the manifest contains no tools;
162
+ unknown names fail. Rails transport defaults to meta `csrf-token`, header
163
+ `X-CSRF-Token`. Both script helpers include `content_security_policy_nonce` when
164
+ available. The runtime tag references the external module `webmcp/runtime.js`;
165
+ it contains no inline executable JavaScript.
166
+
167
+ These two helpers are sufficient: the manifest includes `data-webmcp-autostart`
168
+ by default, and the external module calls `mount()` once when the document is
169
+ ready. No inline bootstrap script is needed. The handle is exposed as
170
+ `WebMCPRuntime.handle`, and the document receives a `webmcp:mounted` event whose
171
+ `detail` is that handle. Registration is asynchronous; `await handle.refresh()`
172
+ waits for reconciliation when you need it.
173
+
174
+ For Inertia or another SPA, put the following in your existing external entry:
175
+
176
+ ```javascript
177
+ // Call after navigation has replaced #webmcp-manifest:
178
+ await globalThis.WebMCPRuntime.handle.refresh();
179
+
180
+ // Install this listener before loading the runtime if you need the initial handle:
181
+ document.addEventListener("webmcp:mounted", ({ detail: handle }) => {
182
+ // Keep the handle for refresh() and dispose().
183
+ }, { once: true });
184
+ ```
185
+
186
+ For manual ownership, use `webmcp_manifest_tag(:list_tasks, autostart: false)`.
187
+ Pin `"webmcp/runtime"` to `"webmcp/runtime.js"` in `config/importmap.rb`, then call
188
+ `mount()` from your external entry:
189
+
190
+ ```javascript
191
+ import { mount } from "webmcp/runtime";
192
+ const handle = mount({ selector: "#webmcp-manifest" });
193
+ ```
194
+
195
+ For Vite/esbuild, import the copied canonical module from your source tree.
196
+ Plain Ruby supports the same opt-out with
197
+ `WebMCP::Manifest.to_script_tag(manifest, autostart: false)`.
198
+
199
+ ```erb
200
+ <%= form_with url: "/tasks", builder: MyFormBuilder,
201
+ webmcp: { tool: "create_task", description: "Create a task", autosubmit: false } do |f| %>
202
+ <%= f.text_field :title, webmcp: { param_description: "Task title" } %>
203
+ <%= f.select :priority, ["normal", "high"], webmcp: { param_description: "Priority" } %>
204
+ <%= f.submit "Create" %>
205
+ <% end %>
206
+ ```
207
+
208
+ Custom `builder:` and `default_form_builder` are preserved. Helpers prepend
209
+ option processing onto the existing FormBuilder and FormTagHelper; no replacement
210
+ builder is installed. `form_tag` and field tag helpers also accept `webmcp:`.
211
+ Only `toolname`, `tooldescription`, `toolautosubmit`, and `toolparamdescription`
212
+ are generated; `param_title` is rejected. Attribute values are escaped even if
213
+ originally marked `html_safe`.
214
+
215
+ ### Origin Trial
216
+
217
+ ```ruby
218
+ # Rails application configuration, or ENV["WEBMCP_ORIGIN_TRIAL_TOKEN"]:
219
+ config.webmcp.origin_trial_token = "YOUR_ORIGIN_TRIAL_TOKEN"
220
+
221
+ # Rack, without Rails:
222
+ use WebMCP::OriginTrial, token: ENV["WEBMCP_ORIGIN_TRIAL_TOKEN"]
223
+ ```
224
+
225
+ ```erb
226
+ <%= webmcp_origin_trial_meta_tag %>
227
+ ```
228
+
229
+ For plain Ruby, `WebMCP::OriginTrial.meta_tag(token)` returns escaped markup.
230
+ The middleware only fills a missing `Origin-Trial` header, preserving even an
231
+ existing empty header. An empty token is a no-op. `warn_on_oac_opt_out: true`
232
+ logs once per middleware instance when `Origin-Agent-Cluster: ?0` is observed;
233
+ it never rewrites that header. Supply `logger:` or set `WebMCP.config.logger`.
234
+ Rack 3 receives lowercase response header names.
235
+
236
+ ### Plain Ruby manifests and runtime assets
237
+
238
+ ```ruby
239
+ manifest = WebMCP::Manifest.build([ListTasks], transport: {})
240
+ html = WebMCP::Manifest.to_script_tag(manifest, nonce: "YOUR_CSP_NONCE")
241
+ # Writes need CSRF transport, for example:
242
+ transport = { csrf: { source: "meta", name: "csrf-token", header: "X-CSRF-Token" } }
243
+ ```
244
+
245
+ Manifests use version 1 and camelCase JSON keys. Empty `paramMap`, absent `title`
246
+ and absent `maxResponseChars` are omitted; annotations contain only true keys.
247
+ Fingerprints cover the effective tool entry plus `transport`. See
248
+ [conformance/README.md](conformance/README.md) for the exact canonical preimages
249
+ and cross-language fixtures.
250
+
251
+ Before packaging a checkout, synchronize the canonical runtime:
252
+
253
+ ```sh
254
+ bundle exec rake webmcp:sync_runtime
255
+ ```
256
+
257
+ This copies `runtime/webmcp-runtime.js` into
258
+ `app/assets/javascripts/webmcp/runtime.js` and records `conformance/RUNTIME.sha256`.
259
+ The Railtie adds asset paths and, for Sprockets, a precompile entry. Other asset
260
+ pipelines can copy the canonical module directly. Do not edit the generated copy.
261
+
262
+ ## Troubleshooting
263
+
264
+ | Symptom (exact text) | Cause | Fix |
265
+ |---|---|---|
266
+ | `document.modelContext` is `undefined` | WebMCP is unavailable or the page is not a secure context | Chrome 149+ with the origin trial token (header or `<meta http-equiv="origin-trial">`) or `chrome://flags/#enable-webmcp-testing`; serve over HTTPS or localhost |
267
+ | `NotAllowedError` from `registerTool` | The document may not use the `tools` Permissions Policy feature (default allowlist `self`) | For cross-origin frames delegate with `allow="tools"` and make sure ancestor `Permissions-Policy` headers permit it |
268
+ | `UnknownError: Failed to parse input arguments` from `executeTool` | Chrome 154 and earlier accept only a JSON **string** input; object input ships in Chrome 155 | Agent side: pass `JSON.stringify(input)` on Chrome ≤ 154. The runtime's `execute` receives an object either way |
269
+ | `SecurityError` from `registerTool` on an older trial build | The response sent `Origin-Agent-Cluster: ?0` (requirement removed from the spec on 2026-09-30, still enforced by older builds) | Stop sending `?0`; enable the OAC opt-out warning to find it |
270
+ | Console: `WebMCP: could not register tool "<name>"` with `InvalidStateError` | Another script in the same document already registered that name | Use unique names; names are unique per document, not per site |
271
+ | Console: `WebMCP: unsupported manifest version; no tools registered.` | Runtime and manifest come from different package versions | Upgrade so both use manifest v1 and the same runtime |
272
+ | Tool result `error.code: "csrf_token_missing"` | A non-GET tool (including a read-only POST) has CSRF transport configured but no readable token on the page | Render the configured token (Rails `csrf_meta_tags`, Django `{% webmcp_csrf_meta %}`, Go/Rust: your own escaped `<meta name="csrf-token">`) or configure a readable cookie source. Without CSRF transport the runtime skips this check |
273
+ | `error.code: "invalid_input"` | The agent sent an undeclared parameter, a missing required one, or the wrong scalar type | Fix the schema or descriptions; the runtime forwards declared parameters only |
274
+ | `error.code: "unknown_outcome"` | A write request failed after dispatch: network error, abort, or a **redirect** (redirects are never followed) | Make the endpoint answer without redirecting (e.g. 401 JSON instead of redirecting to sign-in); never retry automatically |
275
+ | `error.code: "network_error"` on a read | Network failure or redirect rejection after dispatch (an aborted read returns `aborted`) | Check connectivity and answer JSON without redirects; reads may be retried |
276
+ | `error.code: "response_too_large"` | Read response exceeded `maxResponseChars` | Narrow the query or raise the limit; responses are never truncated |
277
+ | `dataOmitted: "invalid_response"` on a write | The endpoint returned 2xx with a non-JSON body | Return JSON from write endpoints |
278
+ | Tools from the previous page remain, or none appear, after client-side navigation | Turbo is handled automatically; other SPAs (Inertia, React routers) are not | After the SPA replaces or removes `#webmcp-manifest`, `await WebMCPRuntime.handle.refresh()`. `webmcp:mounted` only delivers the initial handle. If the first page has no manifest, mount the runtime from your app entry (`mount()`) and keep that handle |
279
+ | A string-returning tool yields `hi` instead of `"hi"` | Chrome 154 does not JSON-quote string results (spec says it should) | The shared runtime always returns an envelope object, so this only affects hand-written tools |
280
+ | Definition error at boot such as `GET endpoints require read_only: true` | The definition violates a rule above | Fix the definition; errors are raised at boot on purpose |
281
+
282
+ ## Security model
283
+
284
+ The server remains the security boundary. Tools call existing same-origin
285
+ endpoints with the current session; those endpoints must enforce authorization,
286
+ CSRF, input validation, range limits and result caps. A schema `maximum` is
287
+ metadata, not server enforcement. Annotations are hints, not security controls.
288
+
289
+ - Endpoint paths must begin with `/`; protocol-relative URLs, backslashes, colons
290
+ and control characters are rejected. The runtime also checks the resolved
291
+ origin and uses same-origin mode/credentials with redirects rejected.
292
+ - GET requires `read_only: true`; read-only POST is allowed. Other methods need a
293
+ CSRF token read at invocation time. Missing tokens stop the request.
294
+ - Only declared input keys are sent. Prototype-related keys are rejected.
295
+ Explicit `param_map` destinations cannot collide or target reserved transport
296
+ fields such as `_method`, `authenticity_token`, or `csrfmiddlewaretoken`.
297
+ - JSON embedding escapes `<`, `>`, `&`, U+2028 and U+2029. This prevents script
298
+ breakout, including mixed-case closing tags and HTML comments. Nonces and HTML
299
+ attributes are independently escaped.
300
+ - The runtime returns structured success/error envelopes and never retries.
301
+ An ambiguous write result is `unknown_outcome`: verify with the user before
302
+ retrying. A successful write with unreadable/oversized output stays successful
303
+ with `dataOmitted`; an oversized read returns `response_too_large`. Responses
304
+ are never silently truncated.
305
+ - Write endpoints should answer in JSON, including their error paths. A write
306
+ tool that receives a 2xx non-JSON body (for example, a 200 HTML sign-in page
307
+ after a session expired) reports `ok: true` with `dataOmitted: "invalid_response"`
308
+ by contract. Return 401/403 JSON instead of rendering a page.
309
+
310
+ **Tool metadata is agent-visible, not just display text.** An AI agent reads
311
+ `tooldescription` / `toolparamdescription` as part of its instructions for what
312
+ the tool does. Do not build these strings from unvalidated user input (profile
313
+ fields, query params, uploaded file names, etc.); a user-controlled value rendered
314
+ into tool metadata is a prompt-injection vector that can hijack the agent's
315
+ behavior. Keep tool names and descriptions as literal strings you write, not
316
+ values derived at request time from data a visitor controls. Define tools at boot
317
+ and freeze the registry; HTML escaping alone does not prevent prompt injection.
318
+
319
+ ## Comparison
320
+
321
+ This comparison follows the reviewed 0.1.0 versions, not a claim about future releases.
322
+
323
+ | Library | Focus | Relationship |
324
+ |---|---|---|
325
+ | `webmcp-rails` 0.1.0 | Declarative `form_with webmcp:` attributes | This API shape informed our helpers. This gem preserves custom builders and does not emit the non-spec `toolparamtitle`. |
326
+ | `active_webmcp` 0.1.0 | Controller actions exposed as page-selected tools; Rails 8.1, importmap and Propshaft | Closest alternative. We also use page opt-in, and add explicit MCP projection, declarative forms, OT helpers, a standalone Rack/Ruby core and shared manifest fixtures. Its controller-centric integration may fit apps that do not need projection. |
327
+
328
+ ## Limitations and validation
329
+
330
+ - Declarative form attributes are wired into `form_with` (which `form_for`
331
+ delegates to on Rails 7.1+), `form_tag` and the field/`FormBuilder` helpers.
332
+
333
+ This is a 0.x subset, not a general JSON Schema rewriting engine. Root schemas
334
+ allow `type: "object"`, `properties`, `required`, and `description`. Properties
335
+ are `string`, `number`, `integer`, `boolean`, or arrays of those scalar types.
336
+ Property metadata supports `enum`, `description`, `default`, `minimum`, `maximum`,
337
+ `maxLength`, and `maxItems`. Nested objects/arrays, `$ref`, composition and other
338
+ keywords are rejected. Metadata numbers must be integers within +/-2^53; floats
339
+ are not accepted. `number` inputs may still be fractional at runtime. Enum,
340
+ bounds and lengths must be enforced by the endpoint.
341
+
342
+ `rename_params` accepts only subset sources, runs before the schema block, and
343
+ updates `required`; collision checks include unchanged names. The schema block
344
+ receives a mutable copy with string keys. GET arrays explicitly emit
345
+ `arrayFormat: "brackets"` by default (`tags[]=a&tags[]=b`); use `:repeat` only for
346
+ endpoints that expect repeated unbracketed keys.
347
+
348
+ No cross-origin exposure, automatic response truncation, or Inertia adapter is
349
+ provided. The runtime's `mount({ selector })` returns `{ refresh(), dispose() }`.
350
+ For Inertia or another SPA, call `WebMCPRuntime.handle.refresh()` after replacing
351
+ the manifest; call `handle.dispose()` when the owner is removed. Turbo refresh is
352
+ handled by the runtime. Browser registration requires WebMCP support; unsupported
353
+ browsers are a no-op. The spec and Origin Trial can change.
354
+
355
+ ```sh
356
+ bundle install
357
+ npm ci --prefix test/integration
358
+ node --test runtime/test/runtime.test.mjs
359
+ bundle exec rake test
360
+ bundle exec rake webmcp:sync_runtime
361
+ bundle exec rake test:integration
362
+ bundle exec rake test:package
363
+ ```
364
+
365
+ The integration tasks require Ruby >= 3.2 for Rails 8, Node.js, and system Google
366
+ Chrome. The Ruby core still supports Ruby >= 3.1 without Rails. Node dependencies
367
+ are isolated under `test/integration`; `rake test` does not boot the dummy app.
368
+
369
+ The unit suite covers real MCP SDK projections, standalone core loading, Rails
370
+ helper/custom-builder behavior, Railtie hooks, independent manifest fixtures,
371
+ XSS vectors, Rack array parsing, middleware semantics, and autostart opt-out.
372
+ The runtime suite also covers browser autostart, repeated module evaluation and
373
+ DOMContentLoaded. `test:integration` exercises the real Rails/Turbo app with CSRF
374
+ protection and strict nonce-based CSP. It fails explicitly if WebMCP is missing.
375
+ `test:package` builds and unpacks the gem into a fresh temporary Rails app,
376
+ precompiles production assets, verifies the served runtime bytes, checks native
377
+ tool registration and CSP, and cleans up the temporary app.
378
+
379
+ On Chrome **154.0.8037.98** with `--enable-features=WebMCPTesting`, all 9 checks
380
+ pass on Rails 8.1 and 8.0: registration and annotations, bracket-array round-trip
381
+ through Rack, a CSRF-protected write, a missing CSRF token blocking the request,
382
+ read/write redirect outcomes, HTTP 500, Turbo tool-set replacement (including a
383
+ first page without a manifest and same-name re-registration), and zero CSP
384
+ violations. The packaged production app also passes.
385
+
386
+ Two Chrome 154 behaviours differ from the spec. It accepts only the legacy
387
+ JSON-string `executeTool` input (object input ships in Chrome 155), so the test
388
+ helper, which plays the agent, falls back to it; the runtime's `execute` receives
389
+ an object either way, and `WEBMCP_STRICT_OBJECT_INPUT=1` runs the object-only gate.
390
+ A tool that returns a plain string comes back as `hi`, without the JSON quotes the
391
+ spec's serialization step implies; the runtime always returns an envelope object.
392
+
393
+ See [the Lane C verification report](test/integration/RESULTS.md) for commands
394
+ and outputs.
data/Rakefile ADDED
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rake/testtask"
4
+ Rake::TestTask.new do |task|
5
+ task.libs << "test"
6
+ task.pattern = "test/**/*_test.rb"
7
+ end
8
+ load File.expand_path("lib/tasks/webmcp.rake", __dir__)
9
+ task default: :test
10
+
11
+ namespace :test do
12
+ desc "Run the strict-CSP Rails/Turbo integration gate in system Google Chrome"
13
+ task :integration do
14
+ sh "node", "--test", "test/integration/browser.test.mjs"
15
+ end
16
+
17
+ desc "Build and install the gem in a fresh Rails app, precompile and test production assets"
18
+ task :package do
19
+ ruby "test/integration/package_smoke.rb"
20
+ end
21
+ end