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 +4 -4
- data/CHANGELOG.md +30 -0
- data/README.md +387 -15
- data/Rakefile +21 -0
- data/app/assets/javascripts/webmcp/runtime.js +344 -0
- data/conformance/README.md +29 -0
- data/conformance/RUNTIME.sha256 +1 -0
- data/conformance/fixtures/escape-parity.json +50 -0
- data/conformance/fixtures/projection-result.json +72 -0
- data/conformance/fixtures/read-array.json +52 -0
- data/conformance/fixtures/write-post.json +67 -0
- data/lib/tasks/webmcp.rake +20 -0
- data/lib/webmcp/form_helper.rb +139 -0
- data/lib/webmcp/manifest.rb +36 -0
- data/lib/webmcp/origin_trial.rb +39 -0
- data/lib/webmcp/projection.rb +70 -0
- data/lib/webmcp/railtie.rb +43 -0
- data/lib/webmcp/registry.rb +30 -0
- data/lib/webmcp/schema.rb +73 -0
- data/lib/webmcp/testing.rb +29 -0
- data/lib/webmcp/tool.rb +103 -0
- data/lib/webmcp/value.rb +79 -0
- data/lib/webmcp/version.rb +1 -1
- data/lib/webmcp/view_helpers.rb +29 -0
- data/lib/webmcp.rb +52 -10
- data/runtime/webmcp-runtime.js +344 -0
- metadata +120 -8
|
@@ -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.
|
|
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-
|
|
11
|
-
dependencies:
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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:
|
|
159
|
+
summary: Server-side WebMCP tools, explicit MCP projections, Rails forms and Origin-Trial
|
|
160
|
+
helpers
|
|
49
161
|
test_files: []
|