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.js CHANGED
@@ -1,11 +1,11 @@
1
1
  'use strict';
2
2
 
3
- var React2 = require('react');
3
+ var React6 = require('react');
4
4
  var jsxRuntime = require('react/jsx-runtime');
5
5
 
6
6
  function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
7
7
 
8
- var React2__default = /*#__PURE__*/_interopDefault(React2);
8
+ var React6__default = /*#__PURE__*/_interopDefault(React6);
9
9
 
10
10
  // src/hooks/useWebMCPTool.ts
11
11
 
@@ -37,16 +37,33 @@ function toolFingerprint(config) {
37
37
  return `${config.name}::${config.description}::${JSON.stringify(config.inputSchema)}::${JSON.stringify(config.outputSchema ?? {})}::${JSON.stringify(config.annotations ?? {})}`;
38
38
  }
39
39
  function useWebMCPTool(config) {
40
- const registeredNameRef = React2.useRef(null);
41
- const configRef = React2.useRef(config);
40
+ const registeredNameRef = React6.useRef(null);
41
+ const configRef = React6.useRef(config);
42
42
  configRef.current = config;
43
43
  const fingerprint = toolFingerprint(config);
44
- React2.useEffect(() => {
44
+ React6.useEffect(() => {
45
45
  const mc = getModelContext();
46
46
  if (!mc) {
47
47
  warnIfUnavailable("useWebMCPTool");
48
48
  return;
49
49
  }
50
+ if (!config.name) {
51
+ throw new Error("[react-webmcp] Tool name must be a non-empty string.");
52
+ }
53
+ if (!config.description) {
54
+ throw new Error(
55
+ `[react-webmcp] Tool "${config.name}" description must be a non-empty string.`
56
+ );
57
+ }
58
+ if (process.env.NODE_ENV !== "production" && config.inputSchema) {
59
+ try {
60
+ JSON.stringify(config.inputSchema);
61
+ } catch (e) {
62
+ throw new Error(
63
+ `[react-webmcp] Tool "${config.name}" inputSchema is not JSON-serializable: ${e instanceof Error ? e.message : String(e)}`
64
+ );
65
+ }
66
+ }
50
67
  if (registeredNameRef.current && registeredNameRef.current !== config.name) {
51
68
  try {
52
69
  mc.unregisterTool(registeredNameRef.current);
@@ -72,10 +89,17 @@ function useWebMCPTool(config) {
72
89
  registeredNameRef.current = config.name;
73
90
  } catch (err) {
74
91
  if (process.env.NODE_ENV !== "production") {
75
- console.error(
76
- `[react-webmcp] Failed to register tool "${config.name}":`,
77
- err
78
- );
92
+ const isDuplicate = err instanceof DOMException && err.name === "InvalidStateError";
93
+ if (isDuplicate) {
94
+ console.warn(
95
+ `[react-webmcp] Tool "${config.name}" is already registered (InvalidStateError). This may indicate a duplicate registration or a missed unregisterTool().`
96
+ );
97
+ } else {
98
+ console.error(
99
+ `[react-webmcp] Failed to register tool "${config.name}":`,
100
+ err
101
+ );
102
+ }
79
103
  }
80
104
  }
81
105
  return () => {
@@ -93,15 +117,25 @@ function toolsFingerprint(tools) {
93
117
  ).join("|");
94
118
  }
95
119
  function useWebMCPContext(config) {
96
- const toolsRef = React2.useRef(config.tools);
120
+ const toolsRef = React6.useRef(config.tools);
97
121
  toolsRef.current = config.tools;
98
122
  const fingerprint = toolsFingerprint(config.tools);
99
- React2.useEffect(() => {
123
+ React6.useEffect(() => {
100
124
  const mc = getModelContext();
101
125
  if (!mc) {
102
126
  warnIfUnavailable("useWebMCPContext");
103
127
  return;
104
128
  }
129
+ for (const tool of toolsRef.current) {
130
+ if (!tool.name) {
131
+ throw new Error("[react-webmcp] Tool name must be a non-empty string.");
132
+ }
133
+ if (!tool.description) {
134
+ throw new Error(
135
+ `[react-webmcp] Tool "${tool.name}" description must be a non-empty string.`
136
+ );
137
+ }
138
+ }
105
139
  const stableTools = toolsRef.current.map((tool, idx) => {
106
140
  const def = {
107
141
  name: tool.name,
@@ -137,7 +171,7 @@ function useWebMCPContext(config) {
137
171
  }, [fingerprint]);
138
172
  }
139
173
  function useToolEvent(event, callback, toolNameFilter) {
140
- React2.useEffect(() => {
174
+ React6.useEffect(() => {
141
175
  const handler = (e) => {
142
176
  const toolName = e.toolName ?? e.detail?.toolName;
143
177
  if (!toolName) return;
@@ -160,8 +194,8 @@ function WebMCPForm({
160
194
  children,
161
195
  ...rest
162
196
  }) {
163
- const formRef = React2.useRef(null);
164
- React2.useEffect(() => {
197
+ const formRef = React6.useRef(null);
198
+ React6.useEffect(() => {
165
199
  const handleActivated = (e) => {
166
200
  const name = e.toolName ?? e.detail?.toolName;
167
201
  if (name === toolName && onToolActivated) {
@@ -181,7 +215,7 @@ function WebMCPForm({
181
215
  window.removeEventListener("toolcancel", handleCancel);
182
216
  };
183
217
  }, [toolName, onToolActivated, onToolCancel]);
184
- const handleSubmit = React2.useCallback(
218
+ const handleSubmit = React6.useCallback(
185
219
  (e) => {
186
220
  if (onSubmit) {
187
221
  onSubmit(e.nativeEvent);
@@ -207,7 +241,7 @@ function WebMCPForm({
207
241
  }
208
242
  );
209
243
  }
210
- var WebMCPInput = React2__default.default.forwardRef(
244
+ var WebMCPInput = React6__default.default.forwardRef(
211
245
  ({ toolParamTitle, toolParamDescription, ...rest }, ref) => {
212
246
  const webmcpAttrs = {};
213
247
  if (toolParamTitle) {
@@ -220,7 +254,7 @@ var WebMCPInput = React2__default.default.forwardRef(
220
254
  }
221
255
  );
222
256
  WebMCPInput.displayName = "WebMCPInput";
223
- var WebMCPSelect = React2__default.default.forwardRef(({ toolParamTitle, toolParamDescription, children, ...rest }, ref) => {
257
+ var WebMCPSelect = React6__default.default.forwardRef(({ toolParamTitle, toolParamDescription, children, ...rest }, ref) => {
224
258
  const webmcpAttrs = {};
225
259
  if (toolParamTitle) {
226
260
  webmcpAttrs.toolparamtitle = toolParamTitle;
@@ -231,7 +265,7 @@ var WebMCPSelect = React2__default.default.forwardRef(({ toolParamTitle, toolPar
231
265
  return /* @__PURE__ */ jsxRuntime.jsx("select", { ref, ...webmcpAttrs, ...rest, children });
232
266
  });
233
267
  WebMCPSelect.displayName = "WebMCPSelect";
234
- var WebMCPTextarea = React2__default.default.forwardRef(({ toolParamTitle, toolParamDescription, ...rest }, ref) => {
268
+ var WebMCPTextarea = React6__default.default.forwardRef(({ toolParamTitle, toolParamDescription, ...rest }, ref) => {
235
269
  const webmcpAttrs = {};
236
270
  if (toolParamTitle) {
237
271
  webmcpAttrs.toolparamtitle = toolParamTitle;
@@ -242,12 +276,12 @@ var WebMCPTextarea = React2__default.default.forwardRef(({ toolParamTitle, toolP
242
276
  return /* @__PURE__ */ jsxRuntime.jsx("textarea", { ref, ...webmcpAttrs, ...rest });
243
277
  });
244
278
  WebMCPTextarea.displayName = "WebMCPTextarea";
245
- var WebMCPReactContext = React2.createContext({
279
+ var WebMCPReactContext = React6.createContext({
246
280
  available: false,
247
281
  testingAvailable: false
248
282
  });
249
283
  function WebMCPProvider({ children }) {
250
- const value = React2.useMemo(
284
+ const value = React6.useMemo(
251
285
  () => ({
252
286
  available: isWebMCPAvailable(),
253
287
  testingAvailable: isWebMCPTestingAvailable()
@@ -257,20 +291,354 @@ function WebMCPProvider({ children }) {
257
291
  return /* @__PURE__ */ jsxRuntime.jsx(WebMCPReactContext.Provider, { value, children });
258
292
  }
259
293
  function useWebMCPStatus() {
260
- return React2.useContext(WebMCPReactContext);
294
+ return React6.useContext(WebMCPReactContext);
295
+ }
296
+ function extractOptions(children) {
297
+ const results = [];
298
+ React6__default.default.Children.toArray(children).forEach((child) => {
299
+ if (!React6__default.default.isValidElement(child)) return;
300
+ const props = child.props;
301
+ if (props.value !== void 0 && props.value !== null) {
302
+ const value = props.value;
303
+ let label;
304
+ if (typeof props.children === "string") {
305
+ label = props.children;
306
+ } else {
307
+ label = String(value);
308
+ }
309
+ results.push({ value, label });
310
+ }
311
+ if (props.children) {
312
+ results.push(...extractOptions(props.children));
313
+ }
314
+ });
315
+ return results;
316
+ }
317
+ function extractFields(children) {
318
+ const fields = [];
319
+ React6__default.default.Children.toArray(children).forEach((child) => {
320
+ if (!React6__default.default.isValidElement(child)) return;
321
+ const props = child.props;
322
+ const inputProps = props.inputProps;
323
+ const slotInput = props.slotProps?.input;
324
+ const name = props.name ?? inputProps?.name ?? slotInput?.name;
325
+ if (name) {
326
+ const field = { name };
327
+ if (props.type !== void 0) field.type = props.type;
328
+ if (props.required !== void 0) field.required = Boolean(props.required);
329
+ if (props.min !== void 0) field.min = Number(props.min);
330
+ if (props.max !== void 0) field.max = Number(props.max);
331
+ if (props.minLength !== void 0) field.minLength = Number(props.minLength);
332
+ if (props.maxLength !== void 0) field.maxLength = Number(props.maxLength);
333
+ if (props.pattern !== void 0) field.pattern = props.pattern;
334
+ if (props.children) {
335
+ const options = extractOptions(props.children);
336
+ if (options.length > 0) {
337
+ field.enumValues = options.map((o) => o.value);
338
+ field.oneOf = options;
339
+ }
340
+ }
341
+ fields.push(field);
342
+ } else if (props.children) {
343
+ fields.push(...extractFields(props.children));
344
+ }
345
+ });
346
+ return fields;
347
+ }
348
+
349
+ // src/adapters/buildSchema.ts
350
+ function mapHtmlTypeToSchemaType(htmlType) {
351
+ switch (htmlType) {
352
+ case "number":
353
+ case "range":
354
+ return "number";
355
+ case "checkbox":
356
+ return "boolean";
357
+ default:
358
+ return "string";
359
+ }
360
+ }
361
+ function buildInputSchema(fields) {
362
+ const properties = {};
363
+ const required = [];
364
+ const sortedFields = [...fields].sort((a, b) => a.name.localeCompare(b.name));
365
+ for (const field of sortedFields) {
366
+ const prop = {
367
+ type: mapHtmlTypeToSchemaType(field.type)
368
+ };
369
+ if (field.title) prop.title = field.title;
370
+ if (field.description) prop.description = field.description;
371
+ if (field.min !== void 0) prop.minimum = field.min;
372
+ if (field.max !== void 0) prop.maximum = field.max;
373
+ if (field.minLength !== void 0) prop.minLength = field.minLength;
374
+ if (field.maxLength !== void 0) prop.maxLength = field.maxLength;
375
+ if (field.pattern) prop.pattern = field.pattern;
376
+ if (field.enumValues && field.enumValues.length > 0) {
377
+ prop.enum = field.enumValues;
378
+ }
379
+ if (field.oneOf && field.oneOf.length > 0) {
380
+ prop.oneOf = field.oneOf.map((opt) => ({
381
+ const: opt.value,
382
+ title: opt.label
383
+ }));
384
+ }
385
+ properties[field.name] = prop;
386
+ if (field.required) {
387
+ required.push(field.name);
388
+ }
389
+ }
390
+ const schema = {
391
+ type: "object",
392
+ properties
393
+ };
394
+ if (required.length > 0) {
395
+ schema.required = required.sort();
396
+ }
397
+ return schema;
398
+ }
399
+
400
+ // src/adapters/validateSchema.ts
401
+ function validateSchema(fields, options) {
402
+ if (process.env.NODE_ENV === "production") return;
403
+ const strict = options?.strict ?? false;
404
+ const issues = [];
405
+ const seen = /* @__PURE__ */ new Set();
406
+ for (const field of fields) {
407
+ if (seen.has(field.name)) {
408
+ issues.push(`Duplicate field name "${field.name}".`);
409
+ }
410
+ seen.add(field.name);
411
+ const schemaType = mapHtmlTypeToSchemaType(field.type);
412
+ if (field.pattern !== void 0 && schemaType !== "string") {
413
+ issues.push(
414
+ `Field "${field.name}": pattern is only valid for string types, but type is "${schemaType}".`
415
+ );
416
+ }
417
+ if ((field.min !== void 0 || field.max !== void 0) && schemaType !== "number") {
418
+ issues.push(
419
+ `Field "${field.name}": min/max are only valid for number types, but type is "${schemaType}".`
420
+ );
421
+ }
422
+ if ((field.minLength !== void 0 || field.maxLength !== void 0) && schemaType !== "string") {
423
+ issues.push(
424
+ `Field "${field.name}": minLength/maxLength are only valid for string types, but type is "${schemaType}".`
425
+ );
426
+ }
427
+ if (field.enumValues && field.enumValues.length > 0) {
428
+ for (const val of field.enumValues) {
429
+ const valType = typeof val;
430
+ if (schemaType === "string" && valType !== "string") {
431
+ issues.push(
432
+ `Field "${field.name}": enum value ${JSON.stringify(val)} is not a string.`
433
+ );
434
+ }
435
+ if (schemaType === "number" && valType !== "number") {
436
+ issues.push(
437
+ `Field "${field.name}": enum value ${JSON.stringify(val)} is not a number.`
438
+ );
439
+ }
440
+ if (schemaType === "boolean" && valType !== "boolean") {
441
+ issues.push(
442
+ `Field "${field.name}": enum value ${JSON.stringify(val)} is not a boolean.`
443
+ );
444
+ }
445
+ }
446
+ }
447
+ }
448
+ for (const issue of issues) {
449
+ if (strict) {
450
+ throw new Error(`[react-webmcp] ${issue}`);
451
+ }
452
+ console.warn(`[react-webmcp] ${issue}`);
453
+ }
454
+ }
455
+
456
+ // src/adapters/useSchemaCollector.ts
457
+ var ToolContext = React6.createContext(null);
458
+ function fieldsFingerprint(fields) {
459
+ return fields.map(
460
+ (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 ?? ""}`
461
+ ).join("|");
261
462
  }
463
+ function mergeField(base, override) {
464
+ const result = { ...base };
465
+ for (const key of Object.keys(override)) {
466
+ if (override[key] !== void 0) {
467
+ result[key] = override[key];
468
+ }
469
+ }
470
+ return result;
471
+ }
472
+ function useSchemaCollector({
473
+ children,
474
+ fields: fieldsProp,
475
+ strict
476
+ }) {
477
+ const contextFieldsRef = React6.useRef(/* @__PURE__ */ new Map());
478
+ const [version, setVersion] = React6.useState(0);
479
+ const registerField = React6.useCallback((field) => {
480
+ contextFieldsRef.current.set(field.name, field);
481
+ setVersion((v) => v + 1);
482
+ }, []);
483
+ const unregisterField = React6.useCallback((name) => {
484
+ contextFieldsRef.current.delete(name);
485
+ setVersion((v) => v + 1);
486
+ }, []);
487
+ const childrenFields = extractFields(children);
488
+ const childrenFP = fieldsFingerprint(childrenFields);
489
+ const merged = React6.useMemo(() => {
490
+ const fieldMap = /* @__PURE__ */ new Map();
491
+ for (const field of childrenFields) {
492
+ fieldMap.set(field.name, field);
493
+ }
494
+ if (fieldsProp) {
495
+ for (const [name, overrides] of Object.entries(fieldsProp)) {
496
+ const existing = fieldMap.get(name);
497
+ if (existing) {
498
+ fieldMap.set(name, mergeField(existing, overrides));
499
+ } else {
500
+ fieldMap.set(name, { name, ...overrides });
501
+ }
502
+ }
503
+ }
504
+ for (const [name, field] of contextFieldsRef.current) {
505
+ const existing = fieldMap.get(name);
506
+ if (existing) {
507
+ fieldMap.set(name, mergeField(existing, field));
508
+ } else {
509
+ fieldMap.set(name, field);
510
+ }
511
+ }
512
+ const result = Array.from(fieldMap.values());
513
+ if (process.env.NODE_ENV !== "production") {
514
+ validateSchema(result, { strict });
515
+ }
516
+ return result;
517
+ }, [childrenFP, fieldsProp, version, strict]);
518
+ const mergedFP = fieldsFingerprint(merged);
519
+ const schema = React6.useMemo(
520
+ () => buildInputSchema(merged),
521
+ // eslint-disable-next-line react-hooks/exhaustive-deps
522
+ [mergedFP]
523
+ );
524
+ return { schema, registerField, unregisterField };
525
+ }
526
+ function WebMCPTool({
527
+ name,
528
+ description,
529
+ onExecute,
530
+ fields: fieldsProp,
531
+ strict,
532
+ autoSubmit,
533
+ annotations,
534
+ onToolActivated,
535
+ onToolCancel,
536
+ children
537
+ }) {
538
+ const { schema, registerField, unregisterField } = useSchemaCollector({
539
+ children,
540
+ fields: fieldsProp,
541
+ strict
542
+ });
543
+ const executeRef = React6.useRef(onExecute);
544
+ executeRef.current = onExecute;
545
+ useWebMCPTool({
546
+ name,
547
+ description,
548
+ inputSchema: schema,
549
+ annotations,
550
+ execute: (input) => executeRef.current(input)
551
+ });
552
+ React6.useEffect(() => {
553
+ if (typeof window === "undefined") return;
554
+ if (!onToolActivated && !onToolCancel) return;
555
+ const handleActivated = (e) => {
556
+ const toolName = e.toolName ?? e.detail?.toolName;
557
+ if (toolName === name && onToolActivated) {
558
+ onToolActivated(toolName);
559
+ }
560
+ };
561
+ const handleCancel = (e) => {
562
+ const toolName = e.toolName ?? e.detail?.toolName;
563
+ if (toolName === name && onToolCancel) {
564
+ onToolCancel(toolName);
565
+ }
566
+ };
567
+ window.addEventListener("toolactivated", handleActivated);
568
+ window.addEventListener("toolcancel", handleCancel);
569
+ return () => {
570
+ window.removeEventListener("toolactivated", handleActivated);
571
+ window.removeEventListener("toolcancel", handleCancel);
572
+ };
573
+ }, [name, onToolActivated, onToolCancel]);
574
+ return /* @__PURE__ */ jsxRuntime.jsx(ToolContext.Provider, { value: { registerField, unregisterField }, children });
575
+ }
576
+ WebMCPTool.displayName = "WebMCP.Tool";
577
+ function fieldFingerprint(field) {
578
+ 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 ?? ""}`;
579
+ }
580
+ function useRegisterField(field) {
581
+ const ctx = React6.useContext(ToolContext);
582
+ const fp = fieldFingerprint(field);
583
+ React6.useEffect(() => {
584
+ if (!ctx) {
585
+ if (process.env.NODE_ENV !== "production") {
586
+ console.warn(
587
+ `[react-webmcp] useRegisterField: no WebMCP.Tool context found for field "${field.name}". Wrap this component in a <WebMCP.Tool> to register fields.`
588
+ );
589
+ }
590
+ return;
591
+ }
592
+ ctx.registerField(field);
593
+ return () => {
594
+ try {
595
+ ctx.unregisterField(field.name);
596
+ } catch {
597
+ }
598
+ };
599
+ }, [fp]);
600
+ }
601
+ function WebMCPField({
602
+ children,
603
+ name,
604
+ ...rest
605
+ }) {
606
+ const field = { name, ...rest };
607
+ if (!field.enumValues && !field.oneOf) {
608
+ const options = extractOptions(children);
609
+ if (options.length > 0) {
610
+ field.enumValues = options.map((o) => o.value);
611
+ field.oneOf = options;
612
+ }
613
+ }
614
+ useRegisterField(field);
615
+ return /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children });
616
+ }
617
+ WebMCPField.displayName = "WebMCP.Field";
618
+
619
+ // src/adapters/index.ts
620
+ var WebMCP = { Tool: WebMCPTool, Field: WebMCPField };
262
621
 
622
+ exports.WebMCP = WebMCP;
623
+ exports.WebMCPField = WebMCPField;
263
624
  exports.WebMCPForm = WebMCPForm;
264
625
  exports.WebMCPInput = WebMCPInput;
265
626
  exports.WebMCPProvider = WebMCPProvider;
266
627
  exports.WebMCPSelect = WebMCPSelect;
267
628
  exports.WebMCPTextarea = WebMCPTextarea;
629
+ exports.WebMCPTool = WebMCPTool;
630
+ exports.buildInputSchema = buildInputSchema;
631
+ exports.extractFields = extractFields;
632
+ exports.extractOptions = extractOptions;
268
633
  exports.getModelContext = getModelContext;
269
634
  exports.isWebMCPAvailable = isWebMCPAvailable;
270
635
  exports.isWebMCPTestingAvailable = isWebMCPTestingAvailable;
636
+ exports.useRegisterField = useRegisterField;
637
+ exports.useSchemaCollector = useSchemaCollector;
271
638
  exports.useToolEvent = useToolEvent;
272
639
  exports.useWebMCPContext = useWebMCPContext;
273
640
  exports.useWebMCPStatus = useWebMCPStatus;
274
641
  exports.useWebMCPTool = useWebMCPTool;
642
+ exports.validateSchema = validateSchema;
275
643
  //# sourceMappingURL=index.js.map
276
644
  //# sourceMappingURL=index.js.map