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,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
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Manifest v1 conformance
|
|
2
|
+
|
|
3
|
+
Each JSON fixture has `definition` (snake_case public API inputs), `transport`
|
|
4
|
+
(camelCase manifest transport), and `expected` (one manifest tool entry).
|
|
5
|
+
Compare parsed JSON values, not serialized key order. Fixtures are fixed inputs
|
|
6
|
+
and expected outputs; tests must not regenerate expected output using the code
|
|
7
|
+
under test. The initial expected fingerprints were calculated independently with
|
|
8
|
+
Python's sorted, compact, UTF-8 JSON serialization and SHA-256.
|
|
9
|
+
|
|
10
|
+
Fingerprint preimages:
|
|
11
|
+
|
|
12
|
+
- Effective tool: the tool entry without `fingerprint`, with an added `transport`
|
|
13
|
+
member containing the manifest transport. Hash recursively sorted keys,
|
|
14
|
+
compact JSON, UTF-8, with no HTML escaping, then prefix the hex digest `sha256:`.
|
|
15
|
+
- MCP source: select the keys actually present in `to_h` from `name`, `title`,
|
|
16
|
+
`description`, `inputSchema`, then canonicalize as above. Absent optional keys
|
|
17
|
+
are omitted; explicit null values are retained. MCP annotations are excluded.
|
|
18
|
+
- Only integer metadata numbers within +/-2^53 are allowed. Array order is
|
|
19
|
+
significant. Empty `paramMap` and absent optional fields are omitted. Empty
|
|
20
|
+
`annotations` is retained. Ruby GET arrays emit `arrayFormat: "brackets"`.
|
|
21
|
+
|
|
22
|
+
`projection-result.json` describes the effective definition after renaming
|
|
23
|
+
`content` to `title`, reducing `limit.maximum` from 500 to 20, and setting an
|
|
24
|
+
explicit endpoint map back to `content`. It can be consumed by languages without
|
|
25
|
+
an MCP SDK bridge.
|
|
26
|
+
|
|
27
|
+
`rake webmcp:sync_runtime` copies the separately maintained canonical runtime into
|
|
28
|
+
Rails assets and writes `RUNTIME.sha256` in sha256sum format. It never edits the
|
|
29
|
+
canonical source. Re-run after runtime changes, before packaging.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
287200d4ef89e16ccd0f0bbb035d9b484c3a761503f3df40f8f1cf5f3baae452 runtime/webmcp-runtime.js
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"definition": {
|
|
3
|
+
"name": "escape_probe",
|
|
4
|
+
"description": "Find </ScRiPt> & <!-- notes
한글 \u001f tab\b end",
|
|
5
|
+
"input_schema": {
|
|
6
|
+
"type": "object",
|
|
7
|
+
"properties": {
|
|
8
|
+
"q": {
|
|
9
|
+
"type": "string",
|
|
10
|
+
"description": "검색어 <b>&</b>"
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"annotations": {
|
|
15
|
+
"read_only": true
|
|
16
|
+
},
|
|
17
|
+
"endpoint": {
|
|
18
|
+
"path": "/api/search",
|
|
19
|
+
"method": "get"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"transport": {
|
|
23
|
+
"csrf": {
|
|
24
|
+
"source": "meta",
|
|
25
|
+
"name": "csrf-token",
|
|
26
|
+
"header": "X-CSRF-Token"
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"expected": {
|
|
30
|
+
"name": "escape_probe",
|
|
31
|
+
"description": "Find </ScRiPt> & <!-- notes
한글 \u001f tab\b end",
|
|
32
|
+
"inputSchema": {
|
|
33
|
+
"type": "object",
|
|
34
|
+
"properties": {
|
|
35
|
+
"q": {
|
|
36
|
+
"type": "string",
|
|
37
|
+
"description": "검색어 <b>&</b>"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
"annotations": {
|
|
42
|
+
"readOnlyHint": true
|
|
43
|
+
},
|
|
44
|
+
"endpoint": {
|
|
45
|
+
"path": "/api/search",
|
|
46
|
+
"method": "GET"
|
|
47
|
+
},
|
|
48
|
+
"fingerprint": "sha256:e2e6e6650c07d308c89a242c69439ff03c3cf83e258a2f951df22e25ce8d1885"
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
{
|
|
2
|
+
"definition": {
|
|
3
|
+
"name": "search_tasks",
|
|
4
|
+
"title": "Search tasks",
|
|
5
|
+
"description": "Search at most 20 tasks.",
|
|
6
|
+
"input_schema": {
|
|
7
|
+
"type": "object",
|
|
8
|
+
"properties": {
|
|
9
|
+
"title": {
|
|
10
|
+
"type": "string"
|
|
11
|
+
},
|
|
12
|
+
"limit": {
|
|
13
|
+
"type": "integer",
|
|
14
|
+
"maximum": 20
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"required": [
|
|
18
|
+
"title"
|
|
19
|
+
]
|
|
20
|
+
},
|
|
21
|
+
"annotations": {
|
|
22
|
+
"read_only": true
|
|
23
|
+
},
|
|
24
|
+
"endpoint": {
|
|
25
|
+
"path": "/api/search",
|
|
26
|
+
"method": "post",
|
|
27
|
+
"param_map": {
|
|
28
|
+
"title": "content"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"max_response_chars": 1500
|
|
32
|
+
},
|
|
33
|
+
"transport": {
|
|
34
|
+
"csrf": {
|
|
35
|
+
"source": "meta",
|
|
36
|
+
"name": "csrf-token",
|
|
37
|
+
"header": "X-CSRF-Token"
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"expected": {
|
|
41
|
+
"name": "search_tasks",
|
|
42
|
+
"title": "Search tasks",
|
|
43
|
+
"description": "Search at most 20 tasks.",
|
|
44
|
+
"inputSchema": {
|
|
45
|
+
"type": "object",
|
|
46
|
+
"properties": {
|
|
47
|
+
"title": {
|
|
48
|
+
"type": "string"
|
|
49
|
+
},
|
|
50
|
+
"limit": {
|
|
51
|
+
"type": "integer",
|
|
52
|
+
"maximum": 20
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
"required": [
|
|
56
|
+
"title"
|
|
57
|
+
]
|
|
58
|
+
},
|
|
59
|
+
"annotations": {
|
|
60
|
+
"readOnlyHint": true
|
|
61
|
+
},
|
|
62
|
+
"endpoint": {
|
|
63
|
+
"path": "/api/search",
|
|
64
|
+
"method": "POST",
|
|
65
|
+
"paramMap": {
|
|
66
|
+
"title": "content"
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
"maxResponseChars": 1500,
|
|
70
|
+
"fingerprint": "sha256:e5a40c0528a542f096bd4bd58ffeac911f009d5d48d67013595906b7e86a1f08"
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"definition": {
|
|
3
|
+
"name": "list_tasks",
|
|
4
|
+
"description": "List tasks tagged by the user.",
|
|
5
|
+
"input_schema": {
|
|
6
|
+
"type": "object",
|
|
7
|
+
"properties": {
|
|
8
|
+
"tags": {
|
|
9
|
+
"type": "array",
|
|
10
|
+
"items": {
|
|
11
|
+
"type": "string"
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"annotations": {
|
|
17
|
+
"read_only": true,
|
|
18
|
+
"untrusted_content": true
|
|
19
|
+
},
|
|
20
|
+
"endpoint": {
|
|
21
|
+
"path": "/api/tasks",
|
|
22
|
+
"method": "get",
|
|
23
|
+
"array_format": "brackets"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"transport": {},
|
|
27
|
+
"expected": {
|
|
28
|
+
"name": "list_tasks",
|
|
29
|
+
"description": "List tasks tagged by the user.",
|
|
30
|
+
"inputSchema": {
|
|
31
|
+
"type": "object",
|
|
32
|
+
"properties": {
|
|
33
|
+
"tags": {
|
|
34
|
+
"type": "array",
|
|
35
|
+
"items": {
|
|
36
|
+
"type": "string"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
"annotations": {
|
|
42
|
+
"readOnlyHint": true,
|
|
43
|
+
"untrustedContentHint": true
|
|
44
|
+
},
|
|
45
|
+
"endpoint": {
|
|
46
|
+
"path": "/api/tasks",
|
|
47
|
+
"method": "GET",
|
|
48
|
+
"arrayFormat": "brackets"
|
|
49
|
+
},
|
|
50
|
+
"fingerprint": "sha256:5507520a0c34b5f1e028a4a76e2b68e1a6cacbc12b3f1c9eadc2d6ef42b143d6"
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"definition": {
|
|
3
|
+
"name": "create_task",
|
|
4
|
+
"title": "Create task",
|
|
5
|
+
"description": "Create one task.",
|
|
6
|
+
"input_schema": {
|
|
7
|
+
"type": "object",
|
|
8
|
+
"properties": {
|
|
9
|
+
"title": {
|
|
10
|
+
"type": "string",
|
|
11
|
+
"maxLength": 120
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"required": [
|
|
15
|
+
"title"
|
|
16
|
+
]
|
|
17
|
+
},
|
|
18
|
+
"annotations": {
|
|
19
|
+
"consequential": true,
|
|
20
|
+
"debugging": false
|
|
21
|
+
},
|
|
22
|
+
"endpoint": {
|
|
23
|
+
"path": "/api/tasks",
|
|
24
|
+
"method": "post",
|
|
25
|
+
"param_map": {
|
|
26
|
+
"title": "content"
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"max_response_chars": 1500
|
|
30
|
+
},
|
|
31
|
+
"transport": {
|
|
32
|
+
"csrf": {
|
|
33
|
+
"source": "meta",
|
|
34
|
+
"name": "csrf-token",
|
|
35
|
+
"header": "X-CSRF-Token"
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"expected": {
|
|
39
|
+
"name": "create_task",
|
|
40
|
+
"title": "Create task",
|
|
41
|
+
"description": "Create one task.",
|
|
42
|
+
"inputSchema": {
|
|
43
|
+
"type": "object",
|
|
44
|
+
"properties": {
|
|
45
|
+
"title": {
|
|
46
|
+
"type": "string",
|
|
47
|
+
"maxLength": 120
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
"required": [
|
|
51
|
+
"title"
|
|
52
|
+
]
|
|
53
|
+
},
|
|
54
|
+
"annotations": {
|
|
55
|
+
"consequentialHint": true
|
|
56
|
+
},
|
|
57
|
+
"endpoint": {
|
|
58
|
+
"path": "/api/tasks",
|
|
59
|
+
"method": "POST",
|
|
60
|
+
"paramMap": {
|
|
61
|
+
"title": "content"
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
"maxResponseChars": 1500,
|
|
65
|
+
"fingerprint": "sha256:bc6e6c1c3aae5362177623f8a0c64919989236bb24571bc6d76a24054d4ca66c"
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
namespace :webmcp do
|
|
7
|
+
desc "Copy the canonical runtime into Rails assets and record its SHA-256"
|
|
8
|
+
task :sync_runtime do
|
|
9
|
+
root = File.expand_path("../..", __dir__)
|
|
10
|
+
source = File.join(root, "runtime/webmcp-runtime.js")
|
|
11
|
+
abort "Missing #{source}; the runtime lane must supply it before packaging" unless File.file?(source)
|
|
12
|
+
destination = File.join(root, "app/assets/javascripts/webmcp/runtime.js")
|
|
13
|
+
FileUtils.mkdir_p(File.dirname(destination))
|
|
14
|
+
FileUtils.cp(source, destination)
|
|
15
|
+
FileUtils.mkdir_p(File.join(root, "conformance"))
|
|
16
|
+
digest = Digest::SHA256.file(destination).hexdigest
|
|
17
|
+
File.write(File.join(root, "conformance/RUNTIME.sha256"), "#{digest} runtime/webmcp-runtime.js\n")
|
|
18
|
+
puts "Synced webmcp/runtime.js (sha256:#{digest})"
|
|
19
|
+
end
|
|
20
|
+
end
|