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.
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WebMCP
4
+ module ViewHelpers
5
+ DEFAULT_TRANSPORT = Value.deep_freeze({ "csrf" => { "source" => "meta", "name" => "csrf-token", "header" => "X-CSRF-Token" } })
6
+
7
+ def webmcp_manifest_tag(*tool_names, transport: DEFAULT_TRANSPORT, nonce: webmcp_nonce, autostart: true)
8
+ tools = tool_names.map { |name| WebMCP.registry.fetch(name) }
9
+ Manifest.to_script_tag(Manifest.build(tools, transport: transport), nonce: nonce, autostart: autostart)
10
+ end
11
+
12
+ def webmcp_runtime_tag
13
+ src = asset_path("webmcp/runtime.js")
14
+ nonce = webmcp_nonce
15
+ nonce_attr = nonce.nil? ? "" : %( nonce="#{Value.escape_html(nonce)}")
16
+ Value.html_safe(%(<script type="module" src="#{Value.escape_html(src)}"#{nonce_attr}></script>))
17
+ end
18
+
19
+ def webmcp_origin_trial_meta_tag
20
+ OriginTrial.meta_tag(WebMCP.config.origin_trial_token)
21
+ end
22
+
23
+ private
24
+
25
+ def webmcp_nonce
26
+ content_security_policy_nonce if respond_to?(:content_security_policy_nonce)
27
+ end
28
+ end
29
+ end
data/lib/webmcp.rb CHANGED
@@ -1,16 +1,58 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
4
+ require "digest"
5
+ require "cgi"
6
+ require "logger"
3
7
  require_relative "webmcp/version"
4
8
 
5
- # WebMCP — Ruby/Rails toolkit for the W3C Web Model Context Protocol.
6
- #
7
- # Status: early development. The WebMCP spec is in Chrome origin trial
8
- # (Chrome 149-156) and its surface is still moving; this gem tracks the
9
- # spec and will ship its first usable release once the API stabilizes.
10
- #
11
- # Planned surface:
12
- # - Tool definitions in Ruby, shared between server-side MCP and WebMCP
13
- # - Declarative form attribute helpers (toolname/tooldescription/...)
14
- # - Rack middleware for serving the Origin-Trial token header
15
9
  module WebMCP
10
+ class DefinitionError < ArgumentError; end
11
+
12
+ class Configuration
13
+ attr_accessor :logger, :origin_trial_token
14
+
15
+ def initialize
16
+ @logger = Logger.new($stderr)
17
+ @origin_trial_token = ENV["WEBMCP_ORIGIN_TRIAL_TOKEN"]
18
+ end
19
+ end
20
+
21
+ class << self
22
+ def config
23
+ @config ||= Configuration.new
24
+ end
25
+
26
+ def configure
27
+ yield config
28
+ end
29
+
30
+ def registry
31
+ @registry ||= Registry.new
32
+ end
33
+
34
+ def register(tool)
35
+ registry.register(tool)
36
+ end
37
+
38
+ def tools
39
+ registry.tools
40
+ end
41
+
42
+ def freeze!
43
+ registry.freeze!
44
+ end
45
+ end
16
46
  end
47
+
48
+ require_relative "webmcp/value"
49
+ require_relative "webmcp/schema"
50
+ require_relative "webmcp/tool"
51
+ require_relative "webmcp/projection"
52
+ require_relative "webmcp/testing"
53
+ require_relative "webmcp/registry"
54
+ require_relative "webmcp/manifest"
55
+ require_relative "webmcp/origin_trial"
56
+ require_relative "webmcp/form_helper"
57
+ require_relative "webmcp/view_helpers"
58
+ require_relative "webmcp/railtie" if defined?(Rails::Railtie)
@@ -0,0 +1,344 @@
1
+ /**
2
+ * @webmcp/runtime v0.1.0 — manifest v1
3
+ * Registers server-defined tools and invokes their same-origin HTTP endpoints.
4
+ * The application keeps responsibility for authentication, CSRF protection, and
5
+ * authorization; this runtime uses the existing browser session and endpoints.
6
+ */
7
+
8
+ const forbiddenKeys = new Set(['__proto__', 'constructor', 'prototype']);
9
+ const messages = {
10
+ invalid_input: 'Provide only declared parameters with the required types.',
11
+ csrf_token_missing: 'Refresh the page to obtain a CSRF token, then try again.',
12
+ network_error: 'The request failed. Check the connection and try again.',
13
+ aborted: 'The request was cancelled.',
14
+ invalid_response: 'The endpoint returned invalid JSON. Check the endpoint response.',
15
+ response_too_large: 'Narrow the request and try again.',
16
+ unknown_outcome: 'The result is unknown. Do not retry automatically; ask the user to check.',
17
+ };
18
+
19
+ function failure(code, status) {
20
+ return {
21
+ ok: false,
22
+ ...(status === undefined ? {} : { status }),
23
+ error: {
24
+ code,
25
+ message: code === 'http_error'
26
+ ? `The endpoint returned HTTP ${status}. Check the request and your access.`
27
+ : messages[code],
28
+ },
29
+ };
30
+ }
31
+
32
+ function matchesScalar(type, value) {
33
+ switch (type) {
34
+ case 'string': return typeof value === 'string';
35
+ case 'boolean': return typeof value === 'boolean';
36
+ case 'number': return typeof value === 'number' && Number.isFinite(value);
37
+ case 'integer': return Number.isInteger(value);
38
+ default: return false;
39
+ }
40
+ }
41
+
42
+ /** Returns { ok: true } or an invalid_input outcome. No schema constraint checks. */
43
+ export function validateInput(tool, input) {
44
+ if (input === null || typeof input !== 'object' || Array.isArray(input)) {
45
+ return failure('invalid_input');
46
+ }
47
+ const schema = tool.inputSchema ?? {};
48
+ const properties = schema.properties ?? {};
49
+ for (const key of schema.required ?? []) {
50
+ if (!Object.hasOwn(input, key)) return failure('invalid_input');
51
+ }
52
+ for (const key of Object.keys(input)) {
53
+ if (forbiddenKeys.has(key) || !Object.hasOwn(properties, key)) {
54
+ return failure('invalid_input');
55
+ }
56
+ const property = properties[key];
57
+ const value = input[key];
58
+ if (property.type === 'array') {
59
+ if (!Array.isArray(value)) return failure('invalid_input');
60
+ // Iteration also rejects sparse slots, which would serialize as null.
61
+ for (const item of value) {
62
+ if (!matchesScalar(property.items?.type, item)) return failure('invalid_input');
63
+ }
64
+ } else if (!matchesScalar(property.type, value)) {
65
+ return failure('invalid_input');
66
+ }
67
+ }
68
+ return { ok: true };
69
+ }
70
+
71
+ function* parameters(tool, input) {
72
+ const properties = tool.inputSchema?.properties ?? {};
73
+ const mapping = tool.endpoint.paramMap ?? {};
74
+ for (const key of Object.keys(input)) {
75
+ if (!forbiddenKeys.has(key) && Object.hasOwn(properties, key)) {
76
+ yield [Object.hasOwn(mapping, key) ? mapping[key] : key, input[key]];
77
+ }
78
+ }
79
+ }
80
+
81
+ /** Encodes declared own keys; null/undefined scalar values are omitted. */
82
+ export function encodeQuery(tool, input) {
83
+ const query = new URLSearchParams();
84
+ for (const [key, value] of parameters(tool, input)) {
85
+ if (value === null || value === undefined) continue;
86
+ if (Array.isArray(value)) {
87
+ const name = tool.endpoint.arrayFormat === 'brackets' ? `${key}[]` : key;
88
+ for (const item of value) query.append(name, String(item));
89
+ } else {
90
+ query.append(key, String(value));
91
+ }
92
+ }
93
+ return query.toString();
94
+ }
95
+
96
+ function csrfToken(csrf, doc) {
97
+ if (csrf.source === 'meta') {
98
+ const name = csrf.name.replace(/["\\]/g, '\\$&');
99
+ return doc?.querySelector(`meta[name="${name}"]`)?.content;
100
+ }
101
+ if (csrf.source === 'cookie') {
102
+ for (const part of (doc?.cookie ?? '').split(';')) {
103
+ const cookie = part.trim();
104
+ const separator = cookie.indexOf('=');
105
+ if (separator < 0 || cookie.slice(0, separator) !== csrf.name) continue;
106
+ const value = cookie.slice(separator + 1);
107
+ try {
108
+ return decodeURIComponent(value);
109
+ } catch {
110
+ return value;
111
+ }
112
+ }
113
+ }
114
+ return undefined;
115
+ }
116
+
117
+ /** Returns { url, options } or a pre-dispatch failure. The caller supplies signal. */
118
+ export function buildRequest(tool, input, transport, doc) {
119
+ const validation = validateInput(tool, input);
120
+ if (!validation.ok) return validation;
121
+ const endpoint = tool.endpoint;
122
+ const path = endpoint?.path;
123
+ if (typeof path !== 'string' || !path.startsWith('/') || path.startsWith('//') ||
124
+ /[\\\u0000-\u001f\u007f]/.test(path)) {
125
+ return failure('invalid_input');
126
+ }
127
+ let url;
128
+ try {
129
+ const origin = doc?.location?.origin ?? globalThis.location?.origin;
130
+ url = new URL(path, origin);
131
+ if (url.origin !== origin) return failure('invalid_input');
132
+ } catch {
133
+ return failure('invalid_input');
134
+ }
135
+ const method = endpoint.method.toUpperCase();
136
+ const headers = { Accept: 'application/json' };
137
+ const options = { method, mode: 'same-origin', credentials: 'same-origin', redirect: 'error', headers };
138
+ const csrf = transport?.csrf;
139
+ if (csrf) {
140
+ const token = csrfToken(csrf, doc);
141
+ if (!token && method !== 'GET') return failure('csrf_token_missing');
142
+ if (token) headers[csrf.header] = token;
143
+ }
144
+ if (method === 'GET') {
145
+ for (const [key, value] of new URLSearchParams(encodeQuery(tool, input))) {
146
+ url.searchParams.append(key, value);
147
+ }
148
+ } else {
149
+ headers['Content-Type'] = 'application/json';
150
+ options.body = JSON.stringify(Object.fromEntries(parameters(tool, input)));
151
+ }
152
+ return { url: url.href, options };
153
+ }
154
+
155
+ /**
156
+ * Pure outcome conversion. phase is 'before-dispatch', 'after-dispatch' (errors),
157
+ * or 'response' (default). A parsed response is { status, data, invalidResponse? }.
158
+ */
159
+ export function toOutcome(tool, value, phase = 'response') {
160
+ const readOnly = tool.annotations?.readOnlyHint === true;
161
+ if (phase === 'before-dispatch') {
162
+ const code = value?.name === 'AbortError' ? 'aborted'
163
+ : value?.code === 'csrf_token_missing' ? 'csrf_token_missing' : 'invalid_input';
164
+ return failure(code);
165
+ }
166
+ if (phase === 'after-dispatch') {
167
+ return failure(readOnly
168
+ ? value?.name === 'AbortError' ? 'aborted' : 'network_error'
169
+ : 'unknown_outcome');
170
+ }
171
+ const { status, data, invalidResponse } = value;
172
+ if (status < 200 || status >= 300) return failure('http_error', status);
173
+ if (status === 204) return { ok: true, status, data: null };
174
+ let omitted = invalidResponse ? 'invalid_response' : undefined;
175
+ if (!omitted && tool.maxResponseChars !== undefined) {
176
+ try {
177
+ if (JSON.stringify(data).length > tool.maxResponseChars) omitted = 'response_too_large';
178
+ } catch {
179
+ omitted = 'invalid_response';
180
+ }
181
+ }
182
+ if (!omitted) return { ok: true, status, data };
183
+ if (readOnly) return failure(omitted, status);
184
+ return {
185
+ ok: true, status, data: null, dataOmitted: omitted,
186
+ message: 'The operation succeeded. Do not repeat it.',
187
+ };
188
+ }
189
+
190
+ function executor(tool, transport, doc, fetcher) {
191
+ return async (inputObject, options = {}) => {
192
+ let phase = 'before-dispatch';
193
+ try {
194
+ const request = buildRequest(tool, inputObject, transport, doc);
195
+ if (request.ok === false) return request;
196
+ const signal = options?.signal;
197
+ if (signal?.aborted) return toOutcome(tool, { name: 'AbortError' }, phase);
198
+ phase = 'after-dispatch';
199
+ const response = await fetcher(request.url, { ...request.options, signal });
200
+ const status = response.status;
201
+ if (status === 204 || status < 200 || status >= 300) {
202
+ return toOutcome(tool, { status });
203
+ }
204
+ let data;
205
+ try {
206
+ data = await response.json();
207
+ } catch (error) {
208
+ // A body read can itself fail after dispatch; only a JSON parse failure
209
+ // proves that the received response was non-JSON.
210
+ if (error?.name !== 'SyntaxError') throw error;
211
+ return toOutcome(tool, { status, invalidResponse: true });
212
+ }
213
+ return toOutcome(tool, { status, data });
214
+ } catch (error) {
215
+ return toOutcome(tool, error, phase);
216
+ }
217
+ };
218
+ }
219
+
220
+ export function mount({
221
+ selector = '#webmcp-manifest', modelContext, fetch: fetcher = globalThis.fetch,
222
+ document: doc = globalThis.document,
223
+ } = {}) {
224
+ const context = modelContext === undefined
225
+ ? doc?.modelContext ?? globalThis.navigator?.modelContext : modelContext;
226
+ if (!context) return { refresh: async () => {}, dispose() {} };
227
+
228
+ const registrations = new Map();
229
+ let queue = Promise.resolve();
230
+ let generation = 0;
231
+ let disposed = false;
232
+ let warnedVersion = false;
233
+ let finishDisposal;
234
+ const disposal = new Promise(resolve => { finishDisposal = resolve; });
235
+
236
+ function remove(name, entry) {
237
+ entry.controller.abort();
238
+ if (registrations.get(name) === entry) registrations.delete(name);
239
+ }
240
+
241
+ function readManifest() {
242
+ try {
243
+ const element = doc?.querySelector(selector);
244
+ if (!element) return { tools: [] };
245
+ const manifest = JSON.parse(element.textContent);
246
+ if (manifest?.webmcpManifestVersion !== 1) {
247
+ if (!warnedVersion) {
248
+ warnedVersion = true;
249
+ console.warn('WebMCP: unsupported manifest version; no tools registered.');
250
+ }
251
+ return { tools: [] };
252
+ }
253
+ if (!Array.isArray(manifest.tools)) throw new TypeError('Manifest tools must be an array.');
254
+ return manifest;
255
+ } catch (error) {
256
+ console.warn('WebMCP: could not read the manifest.', error);
257
+ return { tools: [] };
258
+ }
259
+ }
260
+
261
+ async function reconcile(currentGeneration) {
262
+ if (disposed || currentGeneration !== generation) return;
263
+ const manifest = readManifest();
264
+ const desired = new Map(manifest.tools.map(tool => [tool.name, tool]));
265
+ for (const [name, entry] of registrations) {
266
+ if (!desired.has(name) || desired.get(name).fingerprint !== entry.fingerprint) {
267
+ remove(name, entry);
268
+ }
269
+ }
270
+ for (const [name, tool] of desired) {
271
+ if (disposed || currentGeneration !== generation) return;
272
+ if (registrations.has(name)) continue;
273
+ const entry = { fingerprint: tool.fingerprint, controller: new AbortController() };
274
+ registrations.set(name, entry);
275
+ const registration = (async () => {
276
+ try {
277
+ const definition = {
278
+ name: tool.name, description: tool.description, inputSchema: tool.inputSchema,
279
+ execute: executor(tool, manifest.transport, doc, fetcher),
280
+ };
281
+ if (tool.title !== undefined) definition.title = tool.title;
282
+ if (tool.annotations !== undefined) definition.annotations = tool.annotations;
283
+ await context.registerTool(definition, { signal: entry.controller.signal });
284
+ if (disposed || currentGeneration !== generation) remove(name, entry);
285
+ } catch (error) {
286
+ remove(name, entry);
287
+ console.warn(`WebMCP: could not register tool "${name}".`, error);
288
+ }
289
+ })();
290
+ // Disposal releases callers even if an implementation never settles its
291
+ // registerTool promise. The handler above still cleans up a late result.
292
+ await Promise.race([registration, disposal]);
293
+ }
294
+ }
295
+
296
+ function refresh() {
297
+ if (disposed) return Promise.resolve();
298
+ const currentGeneration = ++generation;
299
+ queue = queue.then(() => reconcile(currentGeneration)).catch(error => {
300
+ console.warn('WebMCP: could not refresh tools.', error);
301
+ });
302
+ return queue;
303
+ }
304
+
305
+ function dispose() {
306
+ if (disposed) return;
307
+ disposed = true;
308
+ ++generation;
309
+ doc?.removeEventListener('turbo:load', onTurboLoad);
310
+ for (const [name, entry] of registrations) remove(name, entry);
311
+ queue = Promise.resolve();
312
+ finishDisposal();
313
+ }
314
+
315
+ function onTurboLoad() { void refresh(); }
316
+ doc?.addEventListener('turbo:load', onTurboLoad);
317
+ void refresh();
318
+ return { refresh, dispose };
319
+ }
320
+
321
+ // Opt in through markup so strict CSP applications need no inline bootstrap.
322
+ if (globalThis.document) {
323
+ const doc = globalThis.document;
324
+ const started = Symbol.for('webmcp.runtime.autostart');
325
+ const autostart = () => {
326
+ if (globalThis[started]) return;
327
+ // Turbo keeps this module loaded across visits, so a page without an
328
+ // opted-in manifest (a sign-in page, say) waits for a later visit that has one.
329
+ if (!doc.querySelector('#webmcp-manifest')?.hasAttribute('data-webmcp-autostart')) {
330
+ doc.addEventListener('turbo:load', autostart);
331
+ return;
332
+ }
333
+ doc.removeEventListener('turbo:load', autostart);
334
+ globalThis[started] = true;
335
+ const handle = mount();
336
+ globalThis.WebMCPRuntime = { handle, mount };
337
+ doc.dispatchEvent(new CustomEvent('webmcp:mounted', { detail: handle }));
338
+ };
339
+ if (doc.readyState === 'loading') {
340
+ doc.addEventListener('DOMContentLoaded', autostart, { once: true });
341
+ } else {
342
+ autostart();
343
+ }
344
+ }
metadata CHANGED
@@ -1,32 +1,143 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: webmcp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.1
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - seunghan Kim
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-08-09 00:00:00.000000000 Z
11
- dependencies: []
12
- description: 'Define agent-callable tools once in Ruby and expose them to browsers
13
- via the WebMCP standard (document.modelContext): declarative form attributes, imperative
14
- registration payloads, and Origin-Trial header middleware. Currently in early development
15
- while the W3C spec stabilizes (Chrome origin trial).'
10
+ date: 2026-10-06 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: minitest
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '5.0'
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '5.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rake
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '13.0'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '13.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: mcp
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '1.1'
47
+ - - "<"
48
+ - !ruby/object:Gem::Version
49
+ version: '2'
50
+ type: :development
51
+ prerelease: false
52
+ version_requirements: !ruby/object:Gem::Requirement
53
+ requirements:
54
+ - - ">="
55
+ - !ruby/object:Gem::Version
56
+ version: '1.1'
57
+ - - "<"
58
+ - !ruby/object:Gem::Version
59
+ version: '2'
60
+ - !ruby/object:Gem::Dependency
61
+ name: actionview
62
+ requirement: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - ">="
65
+ - !ruby/object:Gem::Version
66
+ version: '7.1'
67
+ - - "<"
68
+ - !ruby/object:Gem::Version
69
+ version: '9'
70
+ type: :development
71
+ prerelease: false
72
+ version_requirements: !ruby/object:Gem::Requirement
73
+ requirements:
74
+ - - ">="
75
+ - !ruby/object:Gem::Version
76
+ version: '7.1'
77
+ - - "<"
78
+ - !ruby/object:Gem::Version
79
+ version: '9'
80
+ - !ruby/object:Gem::Dependency
81
+ name: railties
82
+ requirement: !ruby/object:Gem::Requirement
83
+ requirements:
84
+ - - ">="
85
+ - !ruby/object:Gem::Version
86
+ version: '7.1'
87
+ - - "<"
88
+ - !ruby/object:Gem::Version
89
+ version: '9'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '7.1'
97
+ - - "<"
98
+ - !ruby/object:Gem::Version
99
+ version: '9'
100
+ description: A dependency-free Ruby core for immutable WebMCP definitions and safe
101
+ manifests, with optional MCP SDK projection and Rails 7.1+ integration.
16
102
  email:
17
103
  - theqwe2000@gmail.com
18
104
  executables: []
19
105
  extensions: []
20
106
  extra_rdoc_files: []
21
107
  files:
108
+ - CHANGELOG.md
22
109
  - LICENSE.txt
23
110
  - README.md
111
+ - Rakefile
112
+ - app/assets/javascripts/webmcp/runtime.js
113
+ - conformance/README.md
114
+ - conformance/RUNTIME.sha256
115
+ - conformance/fixtures/escape-parity.json
116
+ - conformance/fixtures/projection-result.json
117
+ - conformance/fixtures/read-array.json
118
+ - conformance/fixtures/write-post.json
119
+ - lib/tasks/webmcp.rake
24
120
  - lib/webmcp.rb
121
+ - lib/webmcp/form_helper.rb
122
+ - lib/webmcp/manifest.rb
123
+ - lib/webmcp/origin_trial.rb
124
+ - lib/webmcp/projection.rb
125
+ - lib/webmcp/railtie.rb
126
+ - lib/webmcp/registry.rb
127
+ - lib/webmcp/schema.rb
128
+ - lib/webmcp/testing.rb
129
+ - lib/webmcp/tool.rb
130
+ - lib/webmcp/value.rb
25
131
  - lib/webmcp/version.rb
132
+ - lib/webmcp/view_helpers.rb
133
+ - runtime/webmcp-runtime.js
26
134
  homepage: https://github.com/seunghan91/webmcp
27
135
  licenses:
28
136
  - MIT
29
137
  metadata:
138
+ changelog_uri: https://github.com/seunghan91/webmcp/blob/main/CHANGELOG.md
139
+ bug_tracker_uri: https://github.com/seunghan91/webmcp/issues
140
+ documentation_uri: https://github.com/seunghan91/webmcp#readme
30
141
  source_code_uri: https://github.com/seunghan91/webmcp
31
142
  rubygems_mfa_required: 'true'
32
143
  rdoc_options: []
@@ -45,5 +156,6 @@ required_rubygems_version: !ruby/object:Gem::Requirement
45
156
  requirements: []
46
157
  rubygems_version: 3.6.2
47
158
  specification_version: 4
48
- summary: Ruby/Rails toolkit for WebMCP (W3C Web Model Context Protocol) — early development.
159
+ summary: Server-side WebMCP tools, explicit MCP projections, Rails forms and Origin-Trial
160
+ helpers
49
161
  test_files: []