catalyst-core 1.0.1 → 1.0.2-canary.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.
Files changed (41) hide show
  1. package/dist/errors/registry.js +8 -0
  2. package/dist/errors-index.json +7 -0
  3. package/dist/index.d.ts +1 -1
  4. package/dist/index.jsx +1 -1
  5. package/dist/scripts/build.js +3 -1
  6. package/dist/scripts/preflight.js +81 -1
  7. package/dist/scripts/serve.js +8 -1
  8. package/dist/server/renderer/document/Body.d.ts +0 -11
  9. package/dist/server/renderer/document/Body.jsx +0 -10
  10. package/dist/server/renderer/document/Head.d.ts +0 -13
  11. package/dist/server/renderer/document/Head.jsx +0 -12
  12. package/dist/vite/vite.config.client.js +1 -1
  13. package/dist/vite/vite.config.js +24 -6
  14. package/dist/web-router/components/MetaTag.jsx +6 -13
  15. package/dist/web-router/components/MetaTag.test.jsx +40 -33
  16. package/dist/web-router/components/RouterDataProvider.d.ts +25 -1
  17. package/dist/web-router/components/RouterDataProvider.jsx +35 -1
  18. package/dist/web-router/components/RouterDataProvider.test.jsx +44 -0
  19. package/dist/web-router/context.d.ts +15 -1
  20. package/dist/web-router/context.jsx +11 -0
  21. package/dist/webmcp/WebMcpProvider.jsx +234 -0
  22. package/dist/webmcp/declarative.js +207 -0
  23. package/dist/webmcp/declarative.test.js +122 -0
  24. package/dist/webmcp/errors.js +59 -0
  25. package/dist/webmcp/errors.test.js +47 -0
  26. package/dist/webmcp/index.js +40 -0
  27. package/dist/webmcp/native.js +37 -0
  28. package/dist/webmcp/native.test.js +41 -0
  29. package/dist/webmcp/pageInfo.js +119 -0
  30. package/dist/webmcp/pageInfo.test.js +85 -0
  31. package/dist/webmcp/registry.dom.test.js +175 -0
  32. package/dist/webmcp/registry.js +540 -0
  33. package/dist/webmcp/registry.test.js +228 -0
  34. package/dist/webmcp/routeContext.js +11 -0
  35. package/dist/webmcp/shim-entry.js +8 -0
  36. package/dist/webmcp/shim.js +149 -0
  37. package/dist/webmcp/useTool.js +121 -0
  38. package/package.json +3 -6
  39. package/test/scripts/preflight.test.ts +100 -2
  40. package/test/server/manifestCache.test.ts +13 -0
  41. package/vitest.config.ts +4 -0
@@ -0,0 +1,540 @@
1
+ /**
2
+ * The framework-side WebMCP registry — the piece the discussion argues only
3
+ * a framework (not a component-scoped library) can own.
4
+ *
5
+ * Responsibilities:
6
+ * - Single point that talks to `document.modelContext` (native, or a shim
7
+ * the app opted into via `catalyst-core/webmcp/shim`).
8
+ * - Track every live tool with its *owner* (a hook instance) and the
9
+ * *route path* it was registered under.
10
+ * - Give `WebMcpProvider` a way to abort every tool belonging to routes
11
+ * that are no longer matched (agent navigated away → stale tools die).
12
+ * - Namespacing: if two routes both want "add_to_cart", the later one is
13
+ * rejected loudly rather than silently clobbering.
14
+ *
15
+ * This module holds process-wide singleton state on purpose — there is one
16
+ * `document.modelContext` per document, so there is one registry.
17
+ */
18
+
19
+ import { WebMcpError, WEBMCP_ERROR_CODES, toWebMcpError } from "./errors.js"
20
+
21
+ /** ownerKey -> { name, routePath, receipt, abortController, spec } */
22
+ const live = new Map()
23
+ let _ownerSeq = 0
24
+ let _callSeq = 0
25
+
26
+ export const newOwnerKey = () => `owner_${(++_ownerSeq).toString(36)}`
27
+
28
+ /**
29
+ * Structural equality for JSON-schema-shaped values (inputSchema,
30
+ * annotations) — good enough here because both are always plain
31
+ * JSON-serialisable objects (no functions, no cycles, key order doesn't
32
+ * matter for a schema's meaning).
33
+ */
34
+ function deepEqual(a, b) {
35
+ if (a === b) return true
36
+ if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false
37
+ const aKeys = Object.keys(a)
38
+ const bKeys = Object.keys(b)
39
+ if (aKeys.length !== bKeys.length) return false
40
+ return aKeys.every((k) => Object.prototype.hasOwnProperty.call(b, k) && deepEqual(a[k], b[k]))
41
+ }
42
+
43
+ /**
44
+ * The backend `document.modelContext` — native, or whatever the app
45
+ * installed there itself (e.g. `catalyst-core/webmcp/shim`'s `installShim()`
46
+ * for a browser with no native WebMCP support yet). Registry code makes no
47
+ * distinction between the two; whoever put an object at `document.
48
+ * modelContext` (or `navigator.modelContext`) owns that decision, not this
49
+ * module.
50
+ *
51
+ * When there is NONE (SSR, unit tests with no DOM, or a browser where the
52
+ * app chose not to install a shim), falls back to an in-memory stand-in so
53
+ * the registry's own logic (collision detection, reap, invoke) stays
54
+ * exercisable. The stand-in never touches globals and never persists past
55
+ * the process — it is a safety net, not a substitute for real discovery: an
56
+ * agent watching `document.modelContext` sees nothing while it's active. See
57
+ * WebMcpProvider's one-time console.warn for surfacing that to a developer.
58
+ */
59
+ function ctx() {
60
+ const api = existingModelContext()
61
+ if (api) return api
62
+ if (!_fallbackApi) _fallbackApi = createFallbackBackend()
63
+ return _fallbackApi
64
+ }
65
+
66
+ function existingModelContext() {
67
+ if (typeof document !== "undefined" && document.modelContext) return document.modelContext
68
+ if (typeof navigator !== "undefined" && navigator.modelContext) return navigator.modelContext
69
+ return null
70
+ }
71
+
72
+ let _fallbackApi = null
73
+ function createFallbackBackend() {
74
+ const map = new Map()
75
+ let seq = 0
76
+ return {
77
+ __isFallback: true,
78
+ registerTool(spec) {
79
+ const id = `fb_${(++seq).toString(36)}`
80
+ const replaced = map.has(spec.name)
81
+ map.set(spec.name, spec)
82
+ return {
83
+ id,
84
+ name: spec.name,
85
+ registeredAt: Date.now(),
86
+ replaced,
87
+ unregister: () => map.delete(spec.name),
88
+ }
89
+ },
90
+ getTools() {
91
+ return Array.from(map.values()).map((s) => ({
92
+ name: s.name,
93
+ description: s.description || "",
94
+ inputSchema: s.inputSchema || { type: "object", properties: {} },
95
+ }))
96
+ },
97
+ executeTool(name, args) {
98
+ const s = map.get(name)
99
+ if (!s) {
100
+ const e = new Error(`no tool "${name}"`)
101
+ e.code = "TOOL_NOT_FOUND"
102
+ throw e
103
+ }
104
+ return s.execute(args)
105
+ },
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Lifecycle events for a dev panel or other observer. Emitted here
111
+ * (framework layer) rather than inside any shim, so behaviour is identical
112
+ * whether the backend is native or a shim.
113
+ * webmcp:register { id, name, routePath, replaced }
114
+ * webmcp:unregister { id, name }
115
+ * webmcp:execute:start { callId, name, args }
116
+ * webmcp:execute:end { callId, name, ok, result?, error? }
117
+ */
118
+ function emit(type, detail) {
119
+ if (typeof document === "undefined") return
120
+ try {
121
+ document.dispatchEvent(new CustomEvent(type, { detail }))
122
+ } catch {
123
+ /* CustomEvent unavailable — an observer just won't live-update */
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Whether ANY document.modelContext is present — native or an app-installed
129
+ * shim. False means registrations land in the invisible in-memory fallback.
130
+ */
131
+ export function hasBackend() {
132
+ return !!existingModelContext()
133
+ }
134
+
135
+ /**
136
+ * Whether the active backend is genuinely native — as opposed to nothing
137
+ * (the in-memory fallback) or an app-installed shim (which self-marks with
138
+ * `__isWebMcpShim`, the same convention `catalyst-core/webmcp/shim` uses).
139
+ * A dev panel or other observer can use this to label what it's showing.
140
+ */
141
+ export function isNative() {
142
+ const api = existingModelContext()
143
+ return !!api && !api.__isWebMcpShim
144
+ }
145
+
146
+ /**
147
+ * Register a tool on behalf of a hook instance.
148
+ *
149
+ * @param {string} ownerKey stable id for the calling useTool() instance
150
+ * @param {string} routePath the route pattern this tool lives under ("" if unknown)
151
+ * @param {object} spec { name, description, inputSchema, annotations, execute }
152
+ * @returns {{ ok: true, receipt: object } | { ok: false, error: WebMcpError }}
153
+ */
154
+ export function registerTool(ownerKey, routePath, spec) {
155
+ const api = ctx()
156
+
157
+ // Same owner re-registering (StrictMode remount, fast refresh): tear the
158
+ // old one down first so we don't trip our own collision check.
159
+ if (live.has(ownerKey)) {
160
+ unregisterTool(ownerKey)
161
+ }
162
+
163
+ // Collision check across *other* owners — a genuine "two components want the
164
+ // same tool name right now" clash.
165
+ for (const [key, entry] of live) {
166
+ if (key !== ownerKey && entry.name === spec.name) {
167
+ return {
168
+ ok: false,
169
+ error: new WebMcpError(WEBMCP_ERROR_CODES.DUPLICATE_TOOL, {
170
+ message: `Tool "${spec.name}" is already registered by route "${entry.routePath || "?"}".`,
171
+ details: "Two components asked for the same tool name at the same time. Rename one, or scope it per route.",
172
+ }),
173
+ }
174
+ }
175
+ }
176
+
177
+ const abortController = new AbortController()
178
+
179
+ // The mutable, user-facing half of the spec. updateToolSpec() swaps
180
+ // fields on this object in place, then re-registers with the backend so
181
+ // the agent-visible schema (what Chrome/a shim actually serialised at
182
+ // registerTool time) reflects the change too — mutating this object
183
+ // alone only affects our own validation, not what the agent sees.
184
+ const userSpec = {
185
+ description: spec.description || "",
186
+ inputSchema: spec.inputSchema || { type: "object", properties: {} },
187
+ annotations: spec.annotations || {},
188
+ execute: spec.execute,
189
+ }
190
+
191
+ // Wrap execute so: (a) the AbortSignal is passed through to the tool,
192
+ // (b) a call that arrives after this owner is genuinely gone from the
193
+ // registry is rejected, (c) thrown errors become WebMcpError.
194
+ //
195
+ // Note: we check `live` membership, NOT `abortController.signal.aborted`.
196
+ // A native `document.modelContext` keeps its own reference to this wrapped
197
+ // execute; if the hook re-registers (StrictMode double-invoke, a transient
198
+ // re-render) the old controller is aborted but the tool is still very much
199
+ // usable under a fresh entry. Only a real unmount / route-reap removes the
200
+ // owner from `live`, and that is the true "no longer active" signal.
201
+ function buildWrappedSpec() {
202
+ return {
203
+ name: spec.name,
204
+ description: userSpec.description,
205
+ inputSchema: userSpec.inputSchema,
206
+ annotations: userSpec.annotations,
207
+ execute: async (args = {}) => {
208
+ const callId = `call_${(++_callSeq).toString(36)}`
209
+ emit("webmcp:execute:start", { callId, name: spec.name, args })
210
+ try {
211
+ const current = live.get(ownerKey)
212
+ const stillActive =
213
+ !!current || Array.from(live.values()).some((e) => e.name === spec.name)
214
+ if (!stillActive) {
215
+ throw new WebMcpError(WEBMCP_ERROR_CODES.ABORTED, {
216
+ message: `Tool "${spec.name}" is no longer active (its component unmounted or its route was left).`,
217
+ })
218
+ }
219
+ // Read inputSchema/execute through userSpec (by reference,
220
+ // captured in this closure) so a call in flight when
221
+ // updateToolSpec() mutates it still validates + dispatches
222
+ // against whatever is current right now — not what was
223
+ // current when this particular wrapper was built.
224
+ const signal = current ? current.abortController.signal : abortController.signal
225
+ const argErr = shallowValidate(userSpec.inputSchema, args)
226
+ if (argErr) throw argErr
227
+ const result = await userSpec.execute(args, { signal })
228
+ emit("webmcp:execute:end", { callId, name: spec.name, ok: true, result })
229
+ return result
230
+ } catch (err) {
231
+ const wrapped = toWebMcpError(err)
232
+ emit("webmcp:execute:end", {
233
+ callId,
234
+ name: spec.name,
235
+ ok: false,
236
+ error: { message: wrapped.message, code: wrapped.code },
237
+ })
238
+ throw wrapped
239
+ }
240
+ },
241
+ }
242
+ }
243
+
244
+ const wrappedSpec = buildWrappedSpec()
245
+
246
+ let rawReceipt
247
+ try {
248
+ rawReceipt = api.registerTool(wrappedSpec)
249
+ } catch (err) {
250
+ return { ok: false, error: toWebMcpError(err) }
251
+ }
252
+
253
+ // Normalise: native `registerTool` shapes vary. It may return nothing, a
254
+ // bare id string, a Promise, or an object without `unregister`. We always
255
+ // hand back a receipt with a stable shape; teardown falls back to a
256
+ // best-effort `api.unregisterTool(name)` when the receipt can't do it.
257
+ const receipt = normaliseReceipt(rawReceipt, spec.name, api)
258
+
259
+ live.set(ownerKey, {
260
+ name: spec.name,
261
+ routePath: routePath || "",
262
+ receipt,
263
+ abortController,
264
+ spec: wrappedSpec,
265
+ userSpec,
266
+ api,
267
+ buildWrappedSpec,
268
+ })
269
+ emit("webmcp:register", {
270
+ id: receipt.id,
271
+ name: spec.name,
272
+ routePath: routePath || "",
273
+ replaced: !!receipt.replaced,
274
+ })
275
+ return { ok: true, receipt }
276
+ }
277
+
278
+ /**
279
+ * Update a live tool's route scope in place, without a re-register. Called when
280
+ * the component stays mounted but its matched route changes (transient re-match
281
+ * during navigation, nested-route shuffle). Keeps the same AbortController, so
282
+ * an agent call already in flight is unaffected.
283
+ */
284
+ export function updateToolRoute(ownerKey, routePath) {
285
+ const entry = live.get(ownerKey)
286
+ if (entry) entry.routePath = routePath || ""
287
+ }
288
+
289
+ /**
290
+ * Swap description/inputSchema/annotations on a live tool — used by
291
+ * useTool(spec, deps) when a dep changes. Two things happen, both without
292
+ * touching `ownerKey` or `abortController`:
293
+ *
294
+ * 1. `entry.userSpec` is mutated in place, so a call ALREADY in flight
295
+ * (dispatched through the OLD wrappedSpec.execute closure, which reads
296
+ * userSpec by reference) validates + resolves against the new schema
297
+ * the instant this runs — it never sees a torn-down registration.
298
+ * 2. The backend registration is refreshed: the old receipt is
299
+ * unregistered and a fresh wrappedSpec is registered under the SAME
300
+ * `live` entry, so `document.modelContext` actually serves the updated
301
+ * description/inputSchema to the agent. Mutating `userSpec` alone (as
302
+ * an earlier version of this function did) only fixed our own
303
+ * validation — the backend had already captured the stale schema at
304
+ * the original `registerTool` call and never saw the update.
305
+ *
306
+ * This is NOT the WEBMCP_ABORTED bug from useTool's mount effect: that bug
307
+ * was re-running `registerTool` with a FRESH ownerKey/AbortController on
308
+ * every route recompute. Here the owner and controller are identical across
309
+ * the swap — `live.get(ownerKey)` still resolves for an in-flight call, and
310
+ * `current.abortController` is the same object before and after.
311
+ *
312
+ * `execute` itself is deliberately NOT accepted here — useTool's own
313
+ * specRef-through-liveSpec.execute indirection already means every call
314
+ * dispatches to the latest closure regardless of deps, so there is nothing
315
+ * for a deps change to update on that front.
316
+ */
317
+ export function updateToolSpec(ownerKey, { description, inputSchema, annotations } = {}) {
318
+ const entry = live.get(ownerKey)
319
+ if (!entry) return
320
+
321
+ // Deep-equal, not reference-equal: callers (useTool's deps effect) pass a
322
+ // freshly-built inputSchema/annotations object on every dep change, even
323
+ // when a particular dep (e.g. a UI-only selection that doesn't affect the
324
+ // schema shape) didn't actually change the CONTENT. Re-registering with
325
+ // the backend on every such no-op call would spam unregister/register
326
+ // (visible as spurious REGISTERED events to any observer) for no reason.
327
+ const changed =
328
+ (description !== undefined && description !== entry.userSpec.description) ||
329
+ (inputSchema !== undefined && !deepEqual(inputSchema, entry.userSpec.inputSchema)) ||
330
+ (annotations !== undefined && !deepEqual(annotations, entry.userSpec.annotations))
331
+ if (!changed) return
332
+
333
+ if (description !== undefined) entry.userSpec.description = description
334
+ if (inputSchema !== undefined) entry.userSpec.inputSchema = inputSchema
335
+ if (annotations !== undefined) entry.userSpec.annotations = annotations
336
+
337
+ // Re-register with the backend so the agent-visible schema updates too.
338
+ // Best-effort teardown of the old receipt (native impls vary in whether
339
+ // unregister exists/throws); the new registerTool call is what actually
340
+ // matters — the old entry in `live` is overwritten with the new receipt
341
+ // and wrappedSpec, everything else (ownerKey, abortController) untouched.
342
+ try {
343
+ entry.receipt && entry.receipt.unregister && entry.receipt.unregister()
344
+ } catch {
345
+ /* best effort, same as unregisterTool */
346
+ }
347
+ const newWrappedSpec = entry.buildWrappedSpec()
348
+ let rawReceipt
349
+ try {
350
+ rawReceipt = entry.api.registerTool(newWrappedSpec)
351
+ } catch {
352
+ // Backend rejected the re-register (e.g. a transient native error).
353
+ // Leave the entry's userSpec updated (our own validation is still
354
+ // correct) but keep the OLD receipt/spec live rather than losing the
355
+ // registration entirely.
356
+ return
357
+ }
358
+ entry.receipt = normaliseReceipt(rawReceipt, entry.name, entry.api)
359
+ entry.spec = newWrappedSpec
360
+ emit("webmcp:register", {
361
+ id: entry.receipt.id,
362
+ name: entry.name,
363
+ routePath: entry.routePath,
364
+ replaced: true,
365
+ })
366
+ }
367
+
368
+ /** Unregister one owner's tool (hook unmount). Idempotent. */
369
+ export function unregisterTool(ownerKey) {
370
+ const entry = live.get(ownerKey)
371
+ if (!entry) return
372
+ entry.abortController.abort()
373
+ try {
374
+ entry.receipt && entry.receipt.unregister && entry.receipt.unregister()
375
+ } catch {
376
+ /* native impl may not expose unregister on the receipt — best effort */
377
+ }
378
+ live.delete(ownerKey)
379
+ emit("webmcp:unregister", { id: entry.receipt && entry.receipt.id, name: entry.name })
380
+ }
381
+
382
+ /**
383
+ * Called by WebMcpProvider after every navigation with the set of still-matched
384
+ * route paths. Any tool whose routePath is not in that set is aborted +
385
+ * unregistered. Tools with routePath "" (not route-scoped) are left alone.
386
+ *
387
+ * @param {Set<string>} matchedPaths
388
+ * @returns {string[]} names of tools that were reaped (for logging / a panel)
389
+ */
390
+ export function reapUnmatched(matchedPaths) {
391
+ const reaped = []
392
+ for (const [key, entry] of live) {
393
+ if (!entry.routePath) continue
394
+ if (!matchedPaths.has(entry.routePath)) {
395
+ reaped.push(entry.name)
396
+ unregisterTool(key)
397
+ }
398
+ }
399
+ return reaped
400
+ }
401
+
402
+ /**
403
+ * Invoke a tool by name through the registry's own wrapped execute (which
404
+ * emits the execute:start/end events and applies arg validation + abort
405
+ * checks). Useful to a dev panel so it works regardless of whether the
406
+ * backend exposes a page-side executeTool().
407
+ */
408
+ export async function invokeTool(name, args = {}) {
409
+ for (const entry of live.values()) {
410
+ if (entry.name === name) return entry.spec.execute(args)
411
+ }
412
+ const err = new WebMcpError(WEBMCP_ERROR_CODES.TOOL_NOT_FOUND, {
413
+ message: `No tool named "${name}" is registered.`,
414
+ })
415
+ emit("webmcp:execute:end", { callId: `call_${(++_callSeq).toString(36)}`, name, ok: false, error: { message: err.message, code: err.code } })
416
+ throw err
417
+ }
418
+
419
+ let _receiptSeq = 0
420
+ function normaliseReceipt(raw, name, api) {
421
+ // Object receipt with an unregister fn — use as-is, fill missing fields.
422
+ if (raw && typeof raw === "object" && typeof raw.unregister === "function") {
423
+ return {
424
+ id: raw.id || `r_${(++_receiptSeq).toString(36)}`,
425
+ name: raw.name || name,
426
+ registeredAt: raw.registeredAt || Date.now(),
427
+ replaced: !!raw.replaced,
428
+ unregister: raw.unregister.bind(raw),
429
+ }
430
+ }
431
+ // Anything else (undefined, string id, plain object, Promise): synthesise.
432
+ const id =
433
+ typeof raw === "string"
434
+ ? raw
435
+ : raw && typeof raw === "object" && raw.id
436
+ ? raw.id
437
+ : `r_${(++_receiptSeq).toString(36)}`
438
+ return {
439
+ id,
440
+ name,
441
+ registeredAt: Date.now(),
442
+ replaced: false,
443
+ unregister: () => {
444
+ if (api && typeof api.unregisterTool === "function") {
445
+ try {
446
+ api.unregisterTool(name)
447
+ return true
448
+ } catch {
449
+ return false
450
+ }
451
+ }
452
+ return false
453
+ },
454
+ }
455
+ }
456
+
457
+ /** Test-only: drop all live tools and the fallback backend. */
458
+ export function __resetForTests() {
459
+ for (const key of Array.from(live.keys())) unregisterTool(key)
460
+ live.clear()
461
+ _fallbackApi = null
462
+ }
463
+
464
+ /**
465
+ * Test-only: what the BACKEND (native document.modelContext, an installed
466
+ * shim, or the in-memory fallback in unit tests) thinks the current tool
467
+ * list/schemas are — as opposed to `inspect()`, which reports the
468
+ * registry's own bookkeeping. Used to assert that updateToolSpec()'s
469
+ * re-register actually reaches the agent-visible side, not just our
470
+ * internal validation.
471
+ */
472
+ export function __getBackendTools() {
473
+ return ctx().getTools()
474
+ }
475
+
476
+ /** Snapshot for a dev panel or other observer. */
477
+ export function inspect() {
478
+ return {
479
+ isNative: isNative(),
480
+ tools: Array.from(live.values()).map((e) => ({
481
+ name: e.name,
482
+ routePath: e.routePath,
483
+ receiptId: e.receipt && e.receipt.id,
484
+ registeredAt: e.receipt && e.receipt.registeredAt,
485
+ aborted: e.abortController.signal.aborted,
486
+ })),
487
+ }
488
+ }
489
+
490
+ /**
491
+ * Shallow inputSchema check — top-level `required`, then `properties`,
492
+ * `type: number|string|boolean|integer`, and `minimum`. Enough to reject
493
+ * obviously-wrong agent args with a typed error without pulling in a full
494
+ * JSON Schema validator.
495
+ */
496
+ function shallowValidate(schema, args) {
497
+ if (!schema || schema.type !== "object") return null
498
+ if (Array.isArray(schema.required)) {
499
+ const missing = schema.required.filter((k) => !args || args[k] === undefined || args[k] === null)
500
+ if (missing.length) {
501
+ return new WebMcpError(WEBMCP_ERROR_CODES.INVALID_ARGS, {
502
+ message: `Missing required argument${missing.length > 1 ? "s" : ""}: ${missing.join(", ")}.`,
503
+ })
504
+ }
505
+ }
506
+ if (!schema.properties) return null
507
+ for (const [key, propSchema] of Object.entries(schema.properties)) {
508
+ if (!(key in args) || args[key] === undefined) continue // presence handled by `required` above
509
+ const val = args[key]
510
+ const t = propSchema.type
511
+ if (t === "number" || t === "integer") {
512
+ if (typeof val !== "number" || Number.isNaN(val)) {
513
+ return new WebMcpError(WEBMCP_ERROR_CODES.INVALID_ARGS, {
514
+ message: `Argument "${key}" must be a number.`,
515
+ details: `Received ${JSON.stringify(val)} (${typeof val}).`,
516
+ })
517
+ }
518
+ if (t === "integer" && !Number.isInteger(val)) {
519
+ return new WebMcpError(WEBMCP_ERROR_CODES.INVALID_ARGS, {
520
+ message: `Argument "${key}" must be an integer.`,
521
+ })
522
+ }
523
+ if (typeof propSchema.minimum === "number" && val < propSchema.minimum) {
524
+ return new WebMcpError(WEBMCP_ERROR_CODES.INVALID_ARGS, {
525
+ message: `Argument "${key}" must be >= ${propSchema.minimum}.`,
526
+ details: `Received ${val}.`,
527
+ })
528
+ }
529
+ } else if (t === "string" && typeof val !== "string") {
530
+ return new WebMcpError(WEBMCP_ERROR_CODES.INVALID_ARGS, {
531
+ message: `Argument "${key}" must be a string.`,
532
+ })
533
+ } else if (t === "boolean" && typeof val !== "boolean") {
534
+ return new WebMcpError(WEBMCP_ERROR_CODES.INVALID_ARGS, {
535
+ message: `Argument "${key}" must be a boolean.`,
536
+ })
537
+ }
538
+ }
539
+ return null
540
+ }