webmcp 0.0.1 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ce68cc934f356e7176b4172ac6df65d9726da7b2075916134c57b529437d7709
4
- data.tar.gz: 4128861c35d56f483c70839a6bb57451bd446c59ade477bbeac7ebb9f6bd6169
3
+ metadata.gz: f211ae3ee7700b7d3da74922a27328c3c412955110b8c38c4e560a4363ba565f
4
+ data.tar.gz: 186b9f6456258def8ef8c239cec92be83f062274a29eb03387055ca3e17d4b34
5
5
  SHA512:
6
- metadata.gz: 77996d3ef9cbdfc781afc380d747966d7b5747162d46107db6d43b9475c76e2e70f613dd4c99764d44886fd4065130537a57f1086761654e8359898c709e9ca2
7
- data.tar.gz: d869d16246a0d204467252184c8a38c433b497e3116ac172ee45cc8d06fca7481f7b02b92aa88490912f15463beeba1407d55755f9203ca70cfa7b48e266213a
6
+ metadata.gz: 9802bbe173071fe7ac5dbc9b5c30edd66633ebb11082fa168fa494abc5b84a22c34f631f619d114cb5461a6884922cd8a8c453b586db15dc226eaf60c8f6c708
7
+ data.tar.gz: e452595f3829a74ad941d83a047b56e3968a7ef7de8b912ec905318d1b89bafa497bc2bb35a9fd34c82c28524fe9d0bb78be7702dcda1837405d7b941393ecb8
data/CHANGELOG.md ADDED
@@ -0,0 +1,26 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ - Autostart opted-in manifests from the external runtime under strict CSP, expose
6
+ `WebMCPRuntime.handle`, and emit `webmcp:mounted`; support `autostart: false`.
7
+ - Add explicit Rails 8/Turbo/Chrome integration and built-gem production asset
8
+ smoke tasks. All 9 browser checks pass on Chrome 154.0.8037.98 with Rails 8.1
9
+ and 8.0. Chrome 154 accepts only legacy JSON-string `executeTool` input (object
10
+ input ships in 155), so the agent-side test helper falls back to it;
11
+ `WEBMCP_STRICT_OBJECT_INPUT=1` runs the object-only gate.
12
+
13
+ - Add immutable validated tool definitions and explicit MCP metadata projections,
14
+ source drift checks, and canonical effective-contract fingerprints.
15
+ - Add a boot-time registry, manifest v1 rendering, safe JSON script embedding and
16
+ language-neutral conformance fixtures.
17
+ - Add optional Rails 7.1+ page opt-in helpers, declarative form attributes that
18
+ preserve custom builders, CSP nonce handling, and runtime asset synchronization.
19
+ - Add standalone Rack Origin-Trial middleware and escaped meta tags, preserving
20
+ existing headers and warning once about legacy OAC opt-out behavior.
21
+ - Pin the implementation contract to Draft CG Report 2026-10-02; browser and
22
+ production-asset release gates remain separate from Ruby unit verification.
23
+
24
+ ## 0.0.1
25
+
26
+ - Initial gem skeleton.
data/README.md CHANGED
@@ -1,22 +1,335 @@
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
+ 0.1.0 · Spec baseline: **Draft CG Report 2026-10-02** · Browser test target: **Chrome 154.0.8037.98**. All 9 real-browser checks pass on Rails 8.1 and 8.0, plus a production asset/CSP smoke test (details below).
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
+ ## Intent: share identity, project the rest explicitly
11
6
 
12
- Planned:
7
+ **Surfaces are intentionally different; share identity, project the rest explicitly.**
8
+ A server MCP tool and its browser counterpart can represent the same feature
9
+ while intentionally having different schemas, limits, and execution paths.
13
10
 
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
11
+ | Difference | Server MCP | Browser WebMCP | Reason |
12
+ |---|---|---|---|
13
+ | Field names | `content`, `id` | `title`, `task_id` | Browser agents benefit from names matching visible labels. |
14
+ | Dates | ISO 8601 | `YYYY-MM-DD HH:MM` in the user's timezone | Match what the user sees. |
15
+ | Result limit | 500 | 20 | Keep results useful within the browser's response budget and tab context. |
16
+ | Execution path | Service object → database | Session cookies → existing web endpoint | Preserve session, CSRF and authorization checks. |
19
17
 
20
- ## License
18
+ Identity and descriptions can start from one source. Schema changes, annotations,
19
+ limits and endpoints are explicit projections. MCP and WebMCP annotations are
20
+ different sets: `destructiveHint` does not automatically mean `consequentialHint`.
21
+ Even `readOnlyHint` must be declared again for the browser endpoint.
21
22
 
22
- MIT
23
+ ## Quick start
24
+
25
+ Ruby >= 3.1; no runtime gem dependencies. Rails >= 7.1 and the `mcp` gem >= 1.1
26
+ are optional. Add `gem "webmcp", "~> 0.1.0"` to your Gemfile.
27
+
28
+ ```ruby
29
+ require "webmcp"
30
+
31
+ ListTasks = WebMCP::Tool.define(
32
+ name: "list_tasks",
33
+ title: "List tasks",
34
+ description: "List at most 20 tasks for the signed-in user.",
35
+ input_schema: {
36
+ type: "object",
37
+ properties: {
38
+ tags: { type: "array", items: { type: "string" } },
39
+ completed: { type: "boolean" }
40
+ }
41
+ },
42
+ annotations: { read_only: true, untrusted_content: true },
43
+ endpoint: { path: "/api/tasks", method: :get },
44
+ max_response_chars: 1500
45
+ )
46
+ WebMCP.register(ListTasks)
47
+ ```
48
+
49
+ Definitions are deeply frozen copies. Duplicate names fail. Rails freezes the
50
+ registry after initialization; in a Rack application call `WebMCP.freeze!` after
51
+ registering tools at boot. `WebMCP.tools` returns a frozen list.
52
+
53
+ ### Project an existing MCP tool
54
+
55
+ First obtain the source fingerprint in a console:
56
+
57
+ ```ruby
58
+ WebMCP::Testing.current_source_fingerprint(McpTools::ListTasks)
59
+ # => "sha256:..."
60
+ ```
61
+
62
+ Paste that literal into the checked-in definition. Do not compute it dynamically
63
+ at boot: that would erase the drift baseline.
64
+
65
+ ```ruby
66
+ ListTasksBrowser = WebMCP::Tool.from_mcp(
67
+ McpTools::ListTasks,
68
+ source_fingerprint: "sha256:PASTE_THE_REVIEWED_SOURCE_FINGERPRINT_HERE"
69
+ ) do
70
+ # For a source already in the 0.x subset, rename only top-level properties.
71
+ rename_params content: :title, id: :task_id
72
+ input_schema do |schema| # String keys; already renamed, including required.
73
+ schema["properties"]["limit"]["maximum"] = 20
74
+ schema
75
+ end
76
+ annotations read_only: true, untrusted_content: true
77
+ endpoint path: "/api/tasks", method: :get, param_map: { title: :content }
78
+ max_response_chars 1500
79
+ end
80
+ WebMCP.register(ListTasksBrowser)
81
+ WebMCP::Testing.assert_projection_fresh(ListTasksBrowser)
82
+ ```
83
+
84
+ Missing or stale fingerprints raise `WebMCP::DefinitionError` with the current
85
+ value. Tests can call `assert_projection_fresh` to detect later source changes.
86
+ `projection_notes` describes renames and overrides for debugging and never
87
+ appears in the manifest. Override `name`, `title`, `description` or
88
+ `max_response_chars` in the block as needed. No MCP annotations are inherited;
89
+ even a write projection must call `annotations` explicitly (an empty call is valid).
90
+
91
+ The official MCP SDK adds a `$schema` dialect declaration. That keyword is outside
92
+ this toolkit's deliberately strict 0.x subset. For an SDK schema, use an explicit
93
+ schema projection; do not use `rename_params` on an out-of-subset source:
94
+
95
+ ```ruby
96
+ # Source can be an MCP::Tool subclass or the class returned by MCP::Tool.define.
97
+ BrowserSearch = WebMCP::Tool.from_mcp(
98
+ McpTools::Search,
99
+ source_fingerprint: "sha256:PASTE_THE_REVIEWED_SOURCE_FINGERPRINT_HERE"
100
+ ) do
101
+ input_schema do |schema|
102
+ schema = schema.reject { |key, _| key == "$schema" }
103
+ # If needed, explicitly replace the schema here, including required names.
104
+ schema
105
+ end
106
+ annotations read_only: true
107
+ endpoint path: "/api/search", method: :post
108
+ end
109
+ ```
110
+
111
+ The bridge duck-types `to_h`; it never requires the MCP SDK or invokes its tools.
112
+ The source fingerprint includes the original schema, including `$schema`.
113
+
114
+ ### Rails views: opt in on each page
115
+
116
+ ```erb
117
+ <%= csrf_meta_tags %>
118
+ <%= webmcp_manifest_tag(:list_tasks) %>
119
+ <%= webmcp_runtime_tag %>
120
+ ```
121
+
122
+ Only listed tools are exposed. With no names the manifest contains no tools;
123
+ unknown names fail. Rails transport defaults to meta `csrf-token`, header
124
+ `X-CSRF-Token`. Both script helpers include `content_security_policy_nonce` when
125
+ available. The runtime tag references the external module `webmcp/runtime.js`;
126
+ it contains no inline executable JavaScript.
127
+
128
+ These two helpers are sufficient: the manifest includes `data-webmcp-autostart`
129
+ by default, and the external module calls `mount()` once when the document is
130
+ ready. No inline bootstrap script is needed. The handle is exposed as
131
+ `WebMCPRuntime.handle`, and the document receives a `webmcp:mounted` event whose
132
+ `detail` is that handle. Registration is asynchronous; `await handle.refresh()`
133
+ waits for reconciliation when you need it.
134
+
135
+ For Inertia or another SPA, put the following in your existing external entry:
136
+
137
+ ```javascript
138
+ // Call after navigation has replaced #webmcp-manifest:
139
+ await globalThis.WebMCPRuntime.handle.refresh();
140
+
141
+ // Install this listener before loading the runtime if you need the initial handle:
142
+ document.addEventListener("webmcp:mounted", ({ detail: handle }) => {
143
+ // Keep the handle for refresh() and dispose().
144
+ }, { once: true });
145
+ ```
146
+
147
+ For manual ownership, use `webmcp_manifest_tag(:list_tasks, autostart: false)`.
148
+ Pin `"webmcp/runtime"` to `"webmcp/runtime.js"` in `config/importmap.rb`, then call
149
+ `mount()` from your external entry:
150
+
151
+ ```javascript
152
+ import { mount } from "webmcp/runtime";
153
+ const handle = mount({ selector: "#webmcp-manifest" });
154
+ ```
155
+
156
+ For Vite/esbuild, import the copied canonical module from your source tree.
157
+ Plain Ruby supports the same opt-out with
158
+ `WebMCP::Manifest.to_script_tag(manifest, autostart: false)`.
159
+
160
+ ```erb
161
+ <%= form_with url: "/tasks", builder: MyFormBuilder,
162
+ webmcp: { tool: "create_task", description: "Create a task", autosubmit: false } do |f| %>
163
+ <%= f.text_field :title, webmcp: { param_description: "Task title" } %>
164
+ <%= f.select :priority, ["normal", "high"], webmcp: { param_description: "Priority" } %>
165
+ <%= f.submit "Create" %>
166
+ <% end %>
167
+ ```
168
+
169
+ Custom `builder:` and `default_form_builder` are preserved. Helpers prepend
170
+ option processing onto the existing FormBuilder and FormTagHelper; no replacement
171
+ builder is installed. `form_tag` and field tag helpers also accept `webmcp:`.
172
+ Only `toolname`, `tooldescription`, `toolautosubmit`, and `toolparamdescription`
173
+ are generated; `param_title` is rejected. Attribute values are escaped even if
174
+ originally marked `html_safe`.
175
+
176
+ ### Origin Trial
177
+
178
+ ```ruby
179
+ # Rails application configuration, or ENV["WEBMCP_ORIGIN_TRIAL_TOKEN"]:
180
+ config.webmcp.origin_trial_token = "YOUR_ORIGIN_TRIAL_TOKEN"
181
+
182
+ # Rack, without Rails:
183
+ use WebMCP::OriginTrial, token: ENV["WEBMCP_ORIGIN_TRIAL_TOKEN"]
184
+ ```
185
+
186
+ ```erb
187
+ <%= webmcp_origin_trial_meta_tag %>
188
+ ```
189
+
190
+ For plain Ruby, `WebMCP::OriginTrial.meta_tag(token)` returns escaped markup.
191
+ The middleware only fills a missing `Origin-Trial` header, preserving even an
192
+ existing empty header. An empty token is a no-op. `warn_on_oac_opt_out: true`
193
+ logs once per middleware instance when `Origin-Agent-Cluster: ?0` is observed;
194
+ it never rewrites that header. Supply `logger:` or set `WebMCP.config.logger`.
195
+ Rack 3 receives lowercase response header names.
196
+
197
+ ### Plain Ruby manifests and runtime assets
198
+
199
+ ```ruby
200
+ manifest = WebMCP::Manifest.build([ListTasks], transport: {})
201
+ html = WebMCP::Manifest.to_script_tag(manifest, nonce: "YOUR_CSP_NONCE")
202
+ # Writes need CSRF transport, for example:
203
+ transport = { csrf: { source: "meta", name: "csrf-token", header: "X-CSRF-Token" } }
204
+ ```
205
+
206
+ Manifests use version 1 and camelCase JSON keys. Empty `paramMap`, absent `title`
207
+ and absent `maxResponseChars` are omitted; annotations contain only true keys.
208
+ Fingerprints cover the effective tool entry plus `transport`. See
209
+ [conformance/README.md](conformance/README.md) for the exact canonical preimages
210
+ and cross-language fixtures.
211
+
212
+ Before packaging a checkout, synchronize the canonical runtime:
213
+
214
+ ```sh
215
+ bundle exec rake webmcp:sync_runtime
216
+ ```
217
+
218
+ This copies `runtime/webmcp-runtime.js` into
219
+ `app/assets/javascripts/webmcp/runtime.js` and records `conformance/RUNTIME.sha256`.
220
+ The Railtie adds asset paths and, for Sprockets, a precompile entry. Other asset
221
+ pipelines can copy the canonical module directly. Do not edit the generated copy.
222
+
223
+ ## Security model
224
+
225
+ The server remains the security boundary. Tools call existing same-origin
226
+ endpoints with the current session; those endpoints must enforce authorization,
227
+ CSRF, input validation, range limits and result caps. A schema `maximum` is
228
+ metadata, not server enforcement. Annotations are hints, not security controls.
229
+
230
+ - Endpoint paths must begin with `/`; protocol-relative URLs, backslashes, colons
231
+ and control characters are rejected. The runtime also checks the resolved
232
+ origin and uses same-origin mode/credentials with redirects rejected.
233
+ - GET requires `read_only: true`; read-only POST is allowed. Other methods need a
234
+ CSRF token read at invocation time. Missing tokens stop the request.
235
+ - Only declared input keys are sent. Prototype-related keys are rejected.
236
+ Explicit `param_map` destinations cannot collide or target reserved transport
237
+ fields such as `_method`, `authenticity_token`, or `csrfmiddlewaretoken`.
238
+ - JSON embedding escapes `<`, `>`, `&`, U+2028 and U+2029. This prevents script
239
+ breakout, including mixed-case closing tags and HTML comments. Nonces and HTML
240
+ attributes are independently escaped.
241
+ - The runtime returns structured success/error envelopes and never retries.
242
+ An ambiguous write result is `unknown_outcome`: verify with the user before
243
+ retrying. A successful write with unreadable/oversized output stays successful
244
+ with `dataOmitted`; an oversized read returns `response_too_large`. Responses
245
+ are never silently truncated.
246
+ - Write endpoints should answer in JSON, including their error paths. A write
247
+ tool that receives a 2xx non-JSON body (for example, a 200 HTML sign-in page
248
+ after a session expired) reports `ok: true` with `dataOmitted: "invalid_response"`
249
+ by contract. Return 401/403 JSON instead of rendering a page.
250
+
251
+ **Tool metadata is agent-visible, not just display text.** An AI agent reads
252
+ `tooldescription` / `toolparamdescription` as part of its instructions for what
253
+ the tool does. Do not build these strings from unvalidated user input (profile
254
+ fields, query params, uploaded file names, etc.); a user-controlled value rendered
255
+ into tool metadata is a prompt-injection vector that can hijack the agent's
256
+ behavior. Keep tool names and descriptions as literal strings you write, not
257
+ values derived at request time from data a visitor controls. Define tools at boot
258
+ and freeze the registry; HTML escaping alone does not prevent prompt injection.
259
+
260
+ ## Comparison
261
+
262
+ This comparison follows the reviewed 0.1.0 versions, not a claim about future releases.
263
+
264
+ | Library | Focus | Relationship |
265
+ |---|---|---|
266
+ | `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`. |
267
+ | `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. |
268
+
269
+ ## Limitations and validation
270
+
271
+ - Declarative form attributes are wired into `form_with` (which `form_for`
272
+ delegates to on Rails 7.1+), `form_tag` and the field/`FormBuilder` helpers.
273
+
274
+ This is a 0.x subset, not a general JSON Schema rewriting engine. Root schemas
275
+ allow `type: "object"`, `properties`, `required`, and `description`. Properties
276
+ are `string`, `number`, `integer`, `boolean`, or arrays of those scalar types.
277
+ Property metadata supports `enum`, `description`, `default`, `minimum`, `maximum`,
278
+ `maxLength`, and `maxItems`. Nested objects/arrays, `$ref`, composition and other
279
+ keywords are rejected. Metadata numbers must be integers within +/-2^53; floats
280
+ are not accepted. `number` inputs may still be fractional at runtime. Enum,
281
+ bounds and lengths must be enforced by the endpoint.
282
+
283
+ `rename_params` accepts only subset sources, runs before the schema block, and
284
+ updates `required`; collision checks include unchanged names. The schema block
285
+ receives a mutable copy with string keys. GET arrays explicitly emit
286
+ `arrayFormat: "brackets"` by default (`tags[]=a&tags[]=b`); use `:repeat` only for
287
+ endpoints that expect repeated unbracketed keys.
288
+
289
+ No cross-origin exposure, automatic response truncation, or Inertia adapter is
290
+ provided. The runtime's `mount({ selector })` returns `{ refresh(), dispose() }`.
291
+ For Inertia or another SPA, call `WebMCPRuntime.handle.refresh()` after replacing
292
+ the manifest; call `handle.dispose()` when the owner is removed. Turbo refresh is
293
+ handled by the runtime. Browser registration requires WebMCP support; unsupported
294
+ browsers are a no-op. The spec and Origin Trial can change.
295
+
296
+ ```sh
297
+ bundle install
298
+ npm ci --prefix test/integration
299
+ node --test runtime/test/runtime.test.mjs
300
+ bundle exec rake test
301
+ bundle exec rake webmcp:sync_runtime
302
+ bundle exec rake test:integration
303
+ bundle exec rake test:package
304
+ ```
305
+
306
+ The integration tasks require Ruby >= 3.2 for Rails 8, Node.js, and system Google
307
+ Chrome. The Ruby core still supports Ruby >= 3.1 without Rails. Node dependencies
308
+ are isolated under `test/integration`; `rake test` does not boot the dummy app.
309
+
310
+ The unit suite covers real MCP SDK projections, standalone core loading, Rails
311
+ helper/custom-builder behavior, Railtie hooks, independent manifest fixtures,
312
+ XSS vectors, Rack array parsing, middleware semantics, and autostart opt-out.
313
+ The runtime suite also covers browser autostart, repeated module evaluation and
314
+ DOMContentLoaded. `test:integration` exercises the real Rails/Turbo app with CSRF
315
+ protection and strict nonce-based CSP. It fails explicitly if WebMCP is missing.
316
+ `test:package` builds and unpacks the gem into a fresh temporary Rails app,
317
+ precompiles production assets, verifies the served runtime bytes, checks native
318
+ tool registration and CSP, and cleans up the temporary app.
319
+
320
+ On Chrome **154.0.8037.98** with `--enable-features=WebMCPTesting`, all 9 checks
321
+ pass on Rails 8.1 and 8.0: registration and annotations, bracket-array round-trip
322
+ through Rack, a CSRF-protected write, a missing CSRF token blocking the request,
323
+ read/write redirect outcomes, HTTP 500, Turbo tool-set replacement (including a
324
+ first page without a manifest and same-name re-registration), and zero CSP
325
+ violations. The packaged production app also passes.
326
+
327
+ Two Chrome 154 behaviours differ from the spec. It accepts only the legacy
328
+ JSON-string `executeTool` input (object input ships in Chrome 155), so the test
329
+ helper, which plays the agent, falls back to it; the runtime's `execute` receives
330
+ an object either way, and `WEBMCP_STRICT_OBJECT_INPUT=1` runs the object-only gate.
331
+ A tool that returns a plain string comes back as `hi`, without the JSON quotes the
332
+ spec's serialization step implies; the runtime always returns an envelope object.
333
+
334
+ See [the Lane C verification report](test/integration/RESULTS.md) for commands
335
+ 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