react-webmcp 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
- import React2, { createContext, useRef, useEffect, useCallback, useMemo, useContext } from 'react';
2
- import { jsx } from 'react/jsx-runtime';
1
+ import React6, { createContext, useRef, useEffect, useCallback, useMemo, useContext, useState } from 'react';
2
+ import { jsx, Fragment } from 'react/jsx-runtime';
3
3
 
4
4
  // src/hooks/useWebMCPTool.ts
5
5
 
@@ -41,6 +41,23 @@ function useWebMCPTool(config) {
41
41
  warnIfUnavailable("useWebMCPTool");
42
42
  return;
43
43
  }
44
+ if (!config.name) {
45
+ throw new Error("[react-webmcp] Tool name must be a non-empty string.");
46
+ }
47
+ if (!config.description) {
48
+ throw new Error(
49
+ `[react-webmcp] Tool "${config.name}" description must be a non-empty string.`
50
+ );
51
+ }
52
+ if (process.env.NODE_ENV !== "production" && config.inputSchema) {
53
+ try {
54
+ JSON.stringify(config.inputSchema);
55
+ } catch (e) {
56
+ throw new Error(
57
+ `[react-webmcp] Tool "${config.name}" inputSchema is not JSON-serializable: ${e instanceof Error ? e.message : String(e)}`
58
+ );
59
+ }
60
+ }
44
61
  if (registeredNameRef.current && registeredNameRef.current !== config.name) {
45
62
  try {
46
63
  mc.unregisterTool(registeredNameRef.current);
@@ -66,10 +83,17 @@ function useWebMCPTool(config) {
66
83
  registeredNameRef.current = config.name;
67
84
  } catch (err) {
68
85
  if (process.env.NODE_ENV !== "production") {
69
- console.error(
70
- `[react-webmcp] Failed to register tool "${config.name}":`,
71
- err
72
- );
86
+ const isDuplicate = err instanceof DOMException && err.name === "InvalidStateError";
87
+ if (isDuplicate) {
88
+ console.warn(
89
+ `[react-webmcp] Tool "${config.name}" is already registered (InvalidStateError). This may indicate a duplicate registration or a missed unregisterTool().`
90
+ );
91
+ } else {
92
+ console.error(
93
+ `[react-webmcp] Failed to register tool "${config.name}":`,
94
+ err
95
+ );
96
+ }
73
97
  }
74
98
  }
75
99
  return () => {
@@ -96,6 +120,16 @@ function useWebMCPContext(config) {
96
120
  warnIfUnavailable("useWebMCPContext");
97
121
  return;
98
122
  }
123
+ for (const tool of toolsRef.current) {
124
+ if (!tool.name) {
125
+ throw new Error("[react-webmcp] Tool name must be a non-empty string.");
126
+ }
127
+ if (!tool.description) {
128
+ throw new Error(
129
+ `[react-webmcp] Tool "${tool.name}" description must be a non-empty string.`
130
+ );
131
+ }
132
+ }
99
133
  const stableTools = toolsRef.current.map((tool, idx) => {
100
134
  const def = {
101
135
  name: tool.name,
@@ -201,7 +235,7 @@ function WebMCPForm({
201
235
  }
202
236
  );
203
237
  }
204
- var WebMCPInput = React2.forwardRef(
238
+ var WebMCPInput = React6.forwardRef(
205
239
  ({ toolParamTitle, toolParamDescription, ...rest }, ref) => {
206
240
  const webmcpAttrs = {};
207
241
  if (toolParamTitle) {
@@ -214,7 +248,7 @@ var WebMCPInput = React2.forwardRef(
214
248
  }
215
249
  );
216
250
  WebMCPInput.displayName = "WebMCPInput";
217
- var WebMCPSelect = React2.forwardRef(({ toolParamTitle, toolParamDescription, children, ...rest }, ref) => {
251
+ var WebMCPSelect = React6.forwardRef(({ toolParamTitle, toolParamDescription, children, ...rest }, ref) => {
218
252
  const webmcpAttrs = {};
219
253
  if (toolParamTitle) {
220
254
  webmcpAttrs.toolparamtitle = toolParamTitle;
@@ -225,7 +259,7 @@ var WebMCPSelect = React2.forwardRef(({ toolParamTitle, toolParamDescription, ch
225
259
  return /* @__PURE__ */ jsx("select", { ref, ...webmcpAttrs, ...rest, children });
226
260
  });
227
261
  WebMCPSelect.displayName = "WebMCPSelect";
228
- var WebMCPTextarea = React2.forwardRef(({ toolParamTitle, toolParamDescription, ...rest }, ref) => {
262
+ var WebMCPTextarea = React6.forwardRef(({ toolParamTitle, toolParamDescription, ...rest }, ref) => {
229
263
  const webmcpAttrs = {};
230
264
  if (toolParamTitle) {
231
265
  webmcpAttrs.toolparamtitle = toolParamTitle;
@@ -253,7 +287,332 @@ function WebMCPProvider({ children }) {
253
287
  function useWebMCPStatus() {
254
288
  return useContext(WebMCPReactContext);
255
289
  }
290
+ function extractOptions(children) {
291
+ const results = [];
292
+ React6.Children.toArray(children).forEach((child) => {
293
+ if (!React6.isValidElement(child)) return;
294
+ const props = child.props;
295
+ if (props.value !== void 0 && props.value !== null) {
296
+ const value = props.value;
297
+ let label;
298
+ if (typeof props.children === "string") {
299
+ label = props.children;
300
+ } else {
301
+ label = String(value);
302
+ }
303
+ results.push({ value, label });
304
+ }
305
+ if (props.children) {
306
+ results.push(...extractOptions(props.children));
307
+ }
308
+ });
309
+ return results;
310
+ }
311
+ function extractFields(children) {
312
+ const fields = [];
313
+ React6.Children.toArray(children).forEach((child) => {
314
+ if (!React6.isValidElement(child)) return;
315
+ const props = child.props;
316
+ const inputProps = props.inputProps;
317
+ const slotInput = props.slotProps?.input;
318
+ const name = props.name ?? inputProps?.name ?? slotInput?.name;
319
+ if (name) {
320
+ const field = { name };
321
+ if (props.type !== void 0) field.type = props.type;
322
+ if (props.required !== void 0) field.required = Boolean(props.required);
323
+ if (props.min !== void 0) field.min = Number(props.min);
324
+ if (props.max !== void 0) field.max = Number(props.max);
325
+ if (props.minLength !== void 0) field.minLength = Number(props.minLength);
326
+ if (props.maxLength !== void 0) field.maxLength = Number(props.maxLength);
327
+ if (props.pattern !== void 0) field.pattern = props.pattern;
328
+ if (props.children) {
329
+ const options = extractOptions(props.children);
330
+ if (options.length > 0) {
331
+ field.enumValues = options.map((o) => o.value);
332
+ field.oneOf = options;
333
+ }
334
+ }
335
+ fields.push(field);
336
+ } else if (props.children) {
337
+ fields.push(...extractFields(props.children));
338
+ }
339
+ });
340
+ return fields;
341
+ }
342
+
343
+ // src/adapters/buildSchema.ts
344
+ function mapHtmlTypeToSchemaType(htmlType) {
345
+ switch (htmlType) {
346
+ case "number":
347
+ case "range":
348
+ return "number";
349
+ case "checkbox":
350
+ return "boolean";
351
+ default:
352
+ return "string";
353
+ }
354
+ }
355
+ function buildInputSchema(fields) {
356
+ const properties = {};
357
+ const required = [];
358
+ const sortedFields = [...fields].sort((a, b) => a.name.localeCompare(b.name));
359
+ for (const field of sortedFields) {
360
+ const prop = {
361
+ type: mapHtmlTypeToSchemaType(field.type)
362
+ };
363
+ if (field.title) prop.title = field.title;
364
+ if (field.description) prop.description = field.description;
365
+ if (field.min !== void 0) prop.minimum = field.min;
366
+ if (field.max !== void 0) prop.maximum = field.max;
367
+ if (field.minLength !== void 0) prop.minLength = field.minLength;
368
+ if (field.maxLength !== void 0) prop.maxLength = field.maxLength;
369
+ if (field.pattern) prop.pattern = field.pattern;
370
+ if (field.enumValues && field.enumValues.length > 0) {
371
+ prop.enum = field.enumValues;
372
+ }
373
+ if (field.oneOf && field.oneOf.length > 0) {
374
+ prop.oneOf = field.oneOf.map((opt) => ({
375
+ const: opt.value,
376
+ title: opt.label
377
+ }));
378
+ }
379
+ properties[field.name] = prop;
380
+ if (field.required) {
381
+ required.push(field.name);
382
+ }
383
+ }
384
+ const schema = {
385
+ type: "object",
386
+ properties
387
+ };
388
+ if (required.length > 0) {
389
+ schema.required = required.sort();
390
+ }
391
+ return schema;
392
+ }
393
+
394
+ // src/adapters/validateSchema.ts
395
+ function validateSchema(fields, options) {
396
+ if (process.env.NODE_ENV === "production") return;
397
+ const strict = options?.strict ?? false;
398
+ const issues = [];
399
+ const seen = /* @__PURE__ */ new Set();
400
+ for (const field of fields) {
401
+ if (seen.has(field.name)) {
402
+ issues.push(`Duplicate field name "${field.name}".`);
403
+ }
404
+ seen.add(field.name);
405
+ const schemaType = mapHtmlTypeToSchemaType(field.type);
406
+ if (field.pattern !== void 0 && schemaType !== "string") {
407
+ issues.push(
408
+ `Field "${field.name}": pattern is only valid for string types, but type is "${schemaType}".`
409
+ );
410
+ }
411
+ if ((field.min !== void 0 || field.max !== void 0) && schemaType !== "number") {
412
+ issues.push(
413
+ `Field "${field.name}": min/max are only valid for number types, but type is "${schemaType}".`
414
+ );
415
+ }
416
+ if ((field.minLength !== void 0 || field.maxLength !== void 0) && schemaType !== "string") {
417
+ issues.push(
418
+ `Field "${field.name}": minLength/maxLength are only valid for string types, but type is "${schemaType}".`
419
+ );
420
+ }
421
+ if (field.enumValues && field.enumValues.length > 0) {
422
+ for (const val of field.enumValues) {
423
+ const valType = typeof val;
424
+ if (schemaType === "string" && valType !== "string") {
425
+ issues.push(
426
+ `Field "${field.name}": enum value ${JSON.stringify(val)} is not a string.`
427
+ );
428
+ }
429
+ if (schemaType === "number" && valType !== "number") {
430
+ issues.push(
431
+ `Field "${field.name}": enum value ${JSON.stringify(val)} is not a number.`
432
+ );
433
+ }
434
+ if (schemaType === "boolean" && valType !== "boolean") {
435
+ issues.push(
436
+ `Field "${field.name}": enum value ${JSON.stringify(val)} is not a boolean.`
437
+ );
438
+ }
439
+ }
440
+ }
441
+ }
442
+ for (const issue of issues) {
443
+ if (strict) {
444
+ throw new Error(`[react-webmcp] ${issue}`);
445
+ }
446
+ console.warn(`[react-webmcp] ${issue}`);
447
+ }
448
+ }
449
+
450
+ // src/adapters/useSchemaCollector.ts
451
+ var ToolContext = createContext(null);
452
+ function fieldsFingerprint(fields) {
453
+ return fields.map(
454
+ (f) => `${f.name}::${f.type ?? ""}::${f.required ?? ""}::${f.title ?? ""}::${f.description ?? ""}::${JSON.stringify(f.enumValues ?? [])}::${JSON.stringify(f.oneOf ?? [])}::${f.min ?? ""}::${f.max ?? ""}::${f.minLength ?? ""}::${f.maxLength ?? ""}::${f.pattern ?? ""}`
455
+ ).join("|");
456
+ }
457
+ function mergeField(base, override) {
458
+ const result = { ...base };
459
+ for (const key of Object.keys(override)) {
460
+ if (override[key] !== void 0) {
461
+ result[key] = override[key];
462
+ }
463
+ }
464
+ return result;
465
+ }
466
+ function useSchemaCollector({
467
+ children,
468
+ fields: fieldsProp,
469
+ strict
470
+ }) {
471
+ const contextFieldsRef = useRef(/* @__PURE__ */ new Map());
472
+ const [version, setVersion] = useState(0);
473
+ const registerField = useCallback((field) => {
474
+ contextFieldsRef.current.set(field.name, field);
475
+ setVersion((v) => v + 1);
476
+ }, []);
477
+ const unregisterField = useCallback((name) => {
478
+ contextFieldsRef.current.delete(name);
479
+ setVersion((v) => v + 1);
480
+ }, []);
481
+ const childrenFields = extractFields(children);
482
+ const childrenFP = fieldsFingerprint(childrenFields);
483
+ const merged = useMemo(() => {
484
+ const fieldMap = /* @__PURE__ */ new Map();
485
+ for (const field of childrenFields) {
486
+ fieldMap.set(field.name, field);
487
+ }
488
+ if (fieldsProp) {
489
+ for (const [name, overrides] of Object.entries(fieldsProp)) {
490
+ const existing = fieldMap.get(name);
491
+ if (existing) {
492
+ fieldMap.set(name, mergeField(existing, overrides));
493
+ } else {
494
+ fieldMap.set(name, { name, ...overrides });
495
+ }
496
+ }
497
+ }
498
+ for (const [name, field] of contextFieldsRef.current) {
499
+ const existing = fieldMap.get(name);
500
+ if (existing) {
501
+ fieldMap.set(name, mergeField(existing, field));
502
+ } else {
503
+ fieldMap.set(name, field);
504
+ }
505
+ }
506
+ const result = Array.from(fieldMap.values());
507
+ if (process.env.NODE_ENV !== "production") {
508
+ validateSchema(result, { strict });
509
+ }
510
+ return result;
511
+ }, [childrenFP, fieldsProp, version, strict]);
512
+ const mergedFP = fieldsFingerprint(merged);
513
+ const schema = useMemo(
514
+ () => buildInputSchema(merged),
515
+ // eslint-disable-next-line react-hooks/exhaustive-deps
516
+ [mergedFP]
517
+ );
518
+ return { schema, registerField, unregisterField };
519
+ }
520
+ function WebMCPTool({
521
+ name,
522
+ description,
523
+ onExecute,
524
+ fields: fieldsProp,
525
+ strict,
526
+ autoSubmit,
527
+ annotations,
528
+ onToolActivated,
529
+ onToolCancel,
530
+ children
531
+ }) {
532
+ const { schema, registerField, unregisterField } = useSchemaCollector({
533
+ children,
534
+ fields: fieldsProp,
535
+ strict
536
+ });
537
+ const executeRef = useRef(onExecute);
538
+ executeRef.current = onExecute;
539
+ useWebMCPTool({
540
+ name,
541
+ description,
542
+ inputSchema: schema,
543
+ annotations,
544
+ execute: (input) => executeRef.current(input)
545
+ });
546
+ useEffect(() => {
547
+ if (typeof window === "undefined") return;
548
+ if (!onToolActivated && !onToolCancel) return;
549
+ const handleActivated = (e) => {
550
+ const toolName = e.toolName ?? e.detail?.toolName;
551
+ if (toolName === name && onToolActivated) {
552
+ onToolActivated(toolName);
553
+ }
554
+ };
555
+ const handleCancel = (e) => {
556
+ const toolName = e.toolName ?? e.detail?.toolName;
557
+ if (toolName === name && onToolCancel) {
558
+ onToolCancel(toolName);
559
+ }
560
+ };
561
+ window.addEventListener("toolactivated", handleActivated);
562
+ window.addEventListener("toolcancel", handleCancel);
563
+ return () => {
564
+ window.removeEventListener("toolactivated", handleActivated);
565
+ window.removeEventListener("toolcancel", handleCancel);
566
+ };
567
+ }, [name, onToolActivated, onToolCancel]);
568
+ return /* @__PURE__ */ jsx(ToolContext.Provider, { value: { registerField, unregisterField }, children });
569
+ }
570
+ WebMCPTool.displayName = "WebMCP.Tool";
571
+ function fieldFingerprint(field) {
572
+ return `${field.name}::${field.type ?? ""}::${field.required ?? ""}::${field.title ?? ""}::${field.description ?? ""}::${JSON.stringify(field.enumValues ?? [])}::${JSON.stringify(field.oneOf ?? [])}::${field.min ?? ""}::${field.max ?? ""}::${field.minLength ?? ""}::${field.maxLength ?? ""}::${field.pattern ?? ""}`;
573
+ }
574
+ function useRegisterField(field) {
575
+ const ctx = useContext(ToolContext);
576
+ const fp = fieldFingerprint(field);
577
+ useEffect(() => {
578
+ if (!ctx) {
579
+ if (process.env.NODE_ENV !== "production") {
580
+ console.warn(
581
+ `[react-webmcp] useRegisterField: no WebMCP.Tool context found for field "${field.name}". Wrap this component in a <WebMCP.Tool> to register fields.`
582
+ );
583
+ }
584
+ return;
585
+ }
586
+ ctx.registerField(field);
587
+ return () => {
588
+ try {
589
+ ctx.unregisterField(field.name);
590
+ } catch {
591
+ }
592
+ };
593
+ }, [fp]);
594
+ }
595
+ function WebMCPField({
596
+ children,
597
+ name,
598
+ ...rest
599
+ }) {
600
+ const field = { name, ...rest };
601
+ if (!field.enumValues && !field.oneOf) {
602
+ const options = extractOptions(children);
603
+ if (options.length > 0) {
604
+ field.enumValues = options.map((o) => o.value);
605
+ field.oneOf = options;
606
+ }
607
+ }
608
+ useRegisterField(field);
609
+ return /* @__PURE__ */ jsx(Fragment, { children });
610
+ }
611
+ WebMCPField.displayName = "WebMCP.Field";
612
+
613
+ // src/adapters/index.ts
614
+ var WebMCP = { Tool: WebMCPTool, Field: WebMCPField };
256
615
 
257
- export { WebMCPForm, WebMCPInput, WebMCPProvider, WebMCPSelect, WebMCPTextarea, getModelContext, isWebMCPAvailable, isWebMCPTestingAvailable, useToolEvent, useWebMCPContext, useWebMCPStatus, useWebMCPTool };
616
+ export { WebMCP, WebMCPField, WebMCPForm, WebMCPInput, WebMCPProvider, WebMCPSelect, WebMCPTextarea, WebMCPTool, buildInputSchema, extractFields, extractOptions, getModelContext, isWebMCPAvailable, isWebMCPTestingAvailable, useRegisterField, useSchemaCollector, useToolEvent, useWebMCPContext, useWebMCPStatus, useWebMCPTool, validateSchema };
258
617
  //# sourceMappingURL=index.mjs.map
259
618
  //# sourceMappingURL=index.mjs.map