simple-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/react.cjs CHANGED
@@ -81,6 +81,11 @@ var RegistrationError = class extends SimpleWebMCPError {
81
81
  super(message, "REGISTRATION_ERROR", opts?.cause);
82
82
  }
83
83
  };
84
+ var ValidationError = class extends SimpleWebMCPError {
85
+ constructor(message, opts) {
86
+ super(message, "VALIDATION_ERROR", opts?.cause);
87
+ }
88
+ };
84
89
  var ConfigurationError = class extends SimpleWebMCPError {
85
90
  constructor(message, opts) {
86
91
  super(message, "CONFIGURATION_ERROR", opts?.cause);
@@ -587,7 +592,12 @@ function normalizeResult(value) {
587
592
  }
588
593
  }
589
594
  function normalizeError(err) {
590
- const message = err instanceof Error ? err.message : typeof err === "string" ? err : (() => {
595
+ const message = (() => {
596
+ if (err instanceof Error) return err.message;
597
+ if (typeof err === "string") return err;
598
+ if (err && typeof err === "object" && "message" in err && typeof err.message === "string") {
599
+ return err.message;
600
+ }
591
601
  try {
592
602
  return JSON.stringify(err);
593
603
  } catch {
@@ -599,20 +609,247 @@ function normalizeError(err) {
599
609
  isError: true
600
610
  };
601
611
  }
602
- function wrapExecute(fn, opts) {
612
+
613
+ // src/hooks/engine.ts
614
+ var fallbackCounter = 0;
615
+ function genInvocationId() {
616
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
617
+ return crypto.randomUUID();
618
+ }
619
+ return fallbackInvocationId();
620
+ }
621
+ function fallbackInvocationId() {
622
+ fallbackCounter = (fallbackCounter + 1) % Number.MAX_SAFE_INTEGER;
623
+ return `webmcp_${Date.now().toString(36)}_${fallbackCounter}_${Math.random().toString(36).slice(2, 8)}`;
624
+ }
625
+ function isDenyResult(r) {
626
+ return !!r && typeof r === "object" && r.action === "deny";
627
+ }
628
+ async function runErrorHooks(hooks, ctx) {
629
+ if (!hooks || hooks.length === 0) return;
630
+ for (const h of hooks) {
631
+ try {
632
+ await h(ctx);
633
+ } catch {
634
+ }
635
+ }
636
+ }
637
+ async function runDeniedHooks(hooks, ctx) {
638
+ if (!hooks || hooks.length === 0) return;
639
+ for (const h of hooks) {
640
+ try {
641
+ await h(ctx);
642
+ } catch {
643
+ }
644
+ }
645
+ }
646
+ function createHookedExecute(fn, tool, contract, options) {
603
647
  return async (args, _ctx) => {
648
+ const invocationId = genInvocationId();
649
+ const metadata = {};
650
+ const signal = tool.__activeSignal ?? createNeverAbortedSignal();
651
+ const merged = options.getHooks();
652
+ let currentInput = args;
653
+ const base = { invocationId, tool, contract, signal, metadata };
654
+ const beforeHooks = merged.before ?? [];
655
+ for (const hook of beforeHooks) {
656
+ if (signal.aborted) {
657
+ const abortErr = new DOMException("Aborted", "AbortError");
658
+ await runErrorHooks(merged.error, {
659
+ ...base,
660
+ input: currentInput,
661
+ error: abortErr
662
+ });
663
+ return normalizeError(abortErr);
664
+ }
665
+ let res;
666
+ try {
667
+ const ctx = { ...base, input: currentInput };
668
+ res = await hook(ctx);
669
+ } catch (hookErr) {
670
+ await runErrorHooks(merged.error, {
671
+ ...base,
672
+ input: currentInput,
673
+ error: hookErr
674
+ });
675
+ return normalizeError(hookErr);
676
+ }
677
+ if (res != null && typeof res === "object") {
678
+ if (isDenyResult(res)) {
679
+ const reason = res.message;
680
+ const code = res.code;
681
+ await runDeniedHooks(merged.denied, {
682
+ ...base,
683
+ input: currentInput,
684
+ reason,
685
+ code
686
+ });
687
+ const msg = reason ?? "Tool execution denied";
688
+ return {
689
+ content: [{ type: "text", text: `Denied: ${msg}` }],
690
+ isError: true,
691
+ ...code ? { code } : {}
692
+ };
693
+ }
694
+ if ("input" in res && res.input !== void 0) {
695
+ currentInput = res.input;
696
+ }
697
+ }
698
+ }
699
+ if (signal.aborted) {
700
+ const abortErr = new DOMException("Aborted", "AbortError");
701
+ await runErrorHooks(merged.error, {
702
+ ...base,
703
+ input: currentInput,
704
+ error: abortErr
705
+ });
706
+ return normalizeError(abortErr);
707
+ }
708
+ if (options.validate) {
709
+ try {
710
+ options.validate(currentInput);
711
+ } catch (valErr) {
712
+ await runErrorHooks(merged.error, {
713
+ ...base,
714
+ input: currentInput,
715
+ error: valErr
716
+ });
717
+ return normalizeError(valErr);
718
+ }
719
+ }
720
+ let rawOutput;
604
721
  try {
605
- let result;
606
- const mode = opts?.argMode ?? "object";
607
- if (mode === "spread" && args && typeof args === "object" && !Array.isArray(args)) ; else {
608
- result = await fn(args);
722
+ rawOutput = await fn(currentInput);
723
+ } catch (fnErr) {
724
+ await runErrorHooks(merged.error, {
725
+ ...base,
726
+ input: currentInput,
727
+ error: fnErr
728
+ });
729
+ return normalizeError(fnErr);
730
+ }
731
+ let currentOutput = rawOutput;
732
+ const afterHooks = merged.after ?? [];
733
+ for (const hook of afterHooks) {
734
+ if (signal.aborted) ;
735
+ try {
736
+ const ctx = {
737
+ ...base,
738
+ input: currentInput,
739
+ output: currentOutput
740
+ };
741
+ const res = await hook(ctx);
742
+ if (res != null && typeof res === "object" && "output" in res && res.output !== void 0) {
743
+ currentOutput = res.output;
744
+ }
745
+ } catch (hookErr) {
746
+ await runErrorHooks(merged.error, {
747
+ ...base,
748
+ input: currentInput,
749
+ error: hookErr
750
+ });
751
+ return normalizeError(hookErr);
609
752
  }
610
- return normalizeResult(result);
611
- } catch (err) {
612
- return normalizeError(err);
613
753
  }
754
+ return normalizeResult(currentOutput);
614
755
  };
615
756
  }
757
+ function createNeverAbortedSignal() {
758
+ try {
759
+ return new AbortController().signal;
760
+ } catch {
761
+ return {
762
+ aborted: false,
763
+ addEventListener() {
764
+ },
765
+ removeEventListener() {
766
+ },
767
+ dispatchEvent() {
768
+ return false;
769
+ },
770
+ onabort: null,
771
+ reason: void 0,
772
+ throwIfAborted() {
773
+ }
774
+ };
775
+ }
776
+ }
777
+
778
+ // src/hooks/config.ts
779
+ var GLOBAL_KEY = "__simpleWebmcp_hooks";
780
+ function getStore() {
781
+ const g = globalThis;
782
+ if (!g[GLOBAL_KEY]) {
783
+ g[GLOBAL_KEY] = { hooks: {} };
784
+ }
785
+ return g[GLOBAL_KEY];
786
+ }
787
+ function getGlobalHooks() {
788
+ return getStore().hooks ?? {};
789
+ }
790
+ function configureWebMCP(opts) {
791
+ const store = getStore();
792
+ if (!opts.hooks) return;
793
+ if (opts.replace) {
794
+ store.hooks = normalizeHooks(opts.hooks);
795
+ return;
796
+ }
797
+ store.hooks = mergeHooks(store.hooks, opts.hooks);
798
+ }
799
+ function resetGlobalHooks() {
800
+ const store = getStore();
801
+ store.hooks = {};
802
+ }
803
+ function normalizeHooks(hooks) {
804
+ return {
805
+ before: hooks.before ? [...hooks.before] : void 0,
806
+ after: hooks.after ? [...hooks.after] : void 0,
807
+ error: hooks.error ? [...hooks.error] : void 0,
808
+ denied: hooks.denied ? [...hooks.denied] : void 0
809
+ };
810
+ }
811
+ function mergeHooks(a, b) {
812
+ if (!a && !b) return {};
813
+ if (!a) return normalizeHooks(b);
814
+ if (!b) return a;
815
+ return {
816
+ before: [...a.before ?? [], ...b.before ?? []],
817
+ after: [...a.after ?? [], ...b.after ?? []],
818
+ error: [...a.error ?? [], ...b.error ?? []],
819
+ denied: [...a.denied ?? [], ...b.denied ?? []]
820
+ };
821
+ }
822
+ function mergeHooksOrdered(opts) {
823
+ const globalHooks = opts.globalHooks ?? {};
824
+ const scopedHooks = opts.scopedHooks ?? {};
825
+ const toolHooks = opts.toolHooks ?? {};
826
+ const before = [
827
+ ...globalHooks.before ?? [],
828
+ ...scopedHooks.before ?? [],
829
+ ...toolHooks.before ?? []
830
+ ];
831
+ const after = [
832
+ ...toolHooks.after ?? [],
833
+ ...scopedHooks.after ?? [],
834
+ ...globalHooks.after ?? []
835
+ ];
836
+ const error = [
837
+ ...toolHooks.error ?? [],
838
+ ...scopedHooks.error ?? [],
839
+ ...globalHooks.error ?? []
840
+ ];
841
+ const denied = [
842
+ ...toolHooks.denied ?? [],
843
+ ...scopedHooks.denied ?? [],
844
+ ...globalHooks.denied ?? []
845
+ ];
846
+ const out = {};
847
+ if (before.length) out.before = before;
848
+ if (after.length) out.after = after;
849
+ if (error.length) out.error = error;
850
+ if (denied.length) out.denied = denied;
851
+ return out;
852
+ }
616
853
 
617
854
  // src/webmcp.ts
618
855
  function resolveScope(opts) {
@@ -627,8 +864,24 @@ function webmcp(fn, options) {
627
864
  const anyFn = fn;
628
865
  if (anyFn.__webmcpBrand === true && anyFn.definition) {
629
866
  if (!options || Object.keys(options).length === 0) return anyFn;
867
+ const prevOpts = anyFn.__webmcpOptions || {};
868
+ const mergedOpts = { ...prevOpts, ...options };
869
+ if (prevOpts.hooks || options?.hooks) {
870
+ const prevHooks = prevOpts.hooks ?? {};
871
+ const nextHooks = options.hooks ?? {};
872
+ mergedOpts.hooks = {
873
+ before: [...prevHooks.before ?? [], ...nextHooks.before ?? []],
874
+ after: [...prevHooks.after ?? [], ...nextHooks.after ?? []],
875
+ error: [...prevHooks.error ?? [], ...nextHooks.error ?? []],
876
+ denied: [...prevHooks.denied ?? [], ...nextHooks.denied ?? []]
877
+ };
878
+ for (const k of ["before", "after", "error", "denied"]) {
879
+ if (mergedOpts.hooks[k]?.length === 0) delete mergedOpts.hooks[k];
880
+ }
881
+ if (mergedOpts.hooks && Object.keys(mergedOpts.hooks).length === 0) delete mergedOpts.hooks;
882
+ }
630
883
  const original = anyFn.__fn ?? fn;
631
- return webmcp(original, { ...anyFn.__webmcpOptions || {}, ...options });
884
+ return webmcp(original, mergedOpts);
632
885
  }
633
886
  const name = options?.name ?? toSnakeCase(getFunctionName(fn));
634
887
  if (!name) throw new ConfigurationError('Tool name could not be inferred \u2014 pass {name:"my_tool"}');
@@ -681,15 +934,30 @@ function webmcp(fn, options) {
681
934
  let registrationPromise = null;
682
935
  let unregisterFn = null;
683
936
  let activeController = null;
684
- const wrappedExec = wrapExecute(fn);
937
+ const toolHooks = options?.hooks ? { ...options.hooks } : void 0;
685
938
  const toolWrapper = wrapper;
686
939
  toolWrapper.__webmcpBrand = true;
687
940
  toolWrapper.__fn = fn;
688
941
  toolWrapper.__webmcpOptions = options;
689
942
  if (standard) toolWrapper.__standardSchema = standard;
690
943
  if (outNorm.standard) toolWrapper.__outputStandardSchema = outNorm.standard;
944
+ if (toolHooks) toolWrapper.__hooks = toolHooks;
691
945
  toolWrapper.tool = contract;
692
946
  toolWrapper.definition = contract;
947
+ const hookedExec = createHookedExecute(fn, toolWrapper, contract, {
948
+ getHooks: () => {
949
+ const globalHooks = getGlobalHooks();
950
+ const scopeHooks = toolWrapper.__scopeHooks;
951
+ return mergeHooksOrdered({ globalHooks, scopedHooks: scopeHooks, toolHooks });
952
+ },
953
+ validate: standard ? (input) => {
954
+ const res = standard["~standard"].validate(input);
955
+ if ("issues" in res && res.issues && res.issues.length > 0) {
956
+ const msg = res.issues.map((i) => i.message).join("; ");
957
+ throw new ValidationError(`Validation failed: ${msg}`);
958
+ }
959
+ } : void 0
960
+ });
693
961
  Object.defineProperty(toolWrapper, "status", {
694
962
  get() {
695
963
  return status;
@@ -722,11 +990,16 @@ function webmcp(fn, options) {
722
990
  status = "registering";
723
991
  const controller = new AbortController();
724
992
  activeController = controller;
993
+ toolWrapper.__activeSignal = controller.signal;
725
994
  if (opts?.signal) {
726
995
  if (opts.signal.aborted) {
727
996
  controller.abort();
728
997
  status = "unregistered";
729
998
  activeController = null;
999
+ try {
1000
+ delete toolWrapper.__activeSignal;
1001
+ } catch {
1002
+ }
730
1003
  return () => {
731
1004
  };
732
1005
  }
@@ -741,11 +1014,15 @@ function webmcp(fn, options) {
741
1014
  controller.signal.addEventListener("abort", () => {
742
1015
  if (status !== "unregistered") status = "unregistered";
743
1016
  activeController = null;
1017
+ try {
1018
+ delete toolWrapper.__activeSignal;
1019
+ } catch {
1020
+ }
744
1021
  }, { once: true });
745
1022
  try {
746
1023
  const unregister = await registry.register(contract, {
747
1024
  signal: controller.signal,
748
- execute: wrappedExec
1025
+ execute: hookedExec
749
1026
  });
750
1027
  unregisterFn = unregister;
751
1028
  registrationPromise = Promise.resolve();
@@ -763,6 +1040,10 @@ function webmcp(fn, options) {
763
1040
  }
764
1041
  status = "unregistered";
765
1042
  activeController = null;
1043
+ try {
1044
+ delete toolWrapper.__activeSignal;
1045
+ } catch {
1046
+ }
766
1047
  unregisterFn = null;
767
1048
  };
768
1049
  } catch (err) {
@@ -771,6 +1052,11 @@ function webmcp(fn, options) {
771
1052
  } else {
772
1053
  status = "error";
773
1054
  }
1055
+ activeController = null;
1056
+ try {
1057
+ delete toolWrapper.__activeSignal;
1058
+ } catch {
1059
+ }
774
1060
  registrationPromise = Promise.reject(err);
775
1061
  if (err instanceof SimpleWebMCPError) throw err;
776
1062
  throw err;
@@ -789,6 +1075,10 @@ function webmcp(fn, options) {
789
1075
  }
790
1076
  status = "unregistered";
791
1077
  activeController = null;
1078
+ try {
1079
+ delete toolWrapper.__activeSignal;
1080
+ } catch {
1081
+ }
792
1082
  unregisterFn = null;
793
1083
  registrationPromise = null;
794
1084
  };
@@ -807,9 +1097,30 @@ function webmcp(fn, options) {
807
1097
  webmcp.global = function global(fn, opts) {
808
1098
  return webmcp(fn, { ...opts, global: true });
809
1099
  };
1100
+ webmcp.configure = configureWebMCP;
1101
+ webmcp.getGlobalHooks = getGlobalHooks;
1102
+ webmcp.resetGlobalHooks = resetGlobalHooks;
810
1103
  webmcp.isWebMCPTool = function isWebMCPTool(v) {
811
1104
  return !!v?.__webmcpBrand;
812
1105
  };
1106
+ var WebMCPHooksContext = react.createContext({});
1107
+ function WebMCPProvider({ hooks, children }) {
1108
+ const parent = react.useContext(WebMCPHooksContext);
1109
+ const merged = react.useMemo(() => {
1110
+ if (!hooks) return parent;
1111
+ if (!parent || Object.keys(parent).length === 0) return hooks;
1112
+ return {
1113
+ before: [...parent.before ?? [], ...hooks.before ?? []],
1114
+ after: [...parent.after ?? [], ...hooks.after ?? []],
1115
+ error: [...parent.error ?? [], ...hooks.error ?? []],
1116
+ denied: [...parent.denied ?? [], ...hooks.denied ?? []]
1117
+ };
1118
+ }, [parent, hooks]);
1119
+ return /* @__PURE__ */ jsxRuntime.jsx(WebMCPHooksContext.Provider, { value: merged, children });
1120
+ }
1121
+ function useWebMCPHooksContext() {
1122
+ return react.useContext(WebMCPHooksContext);
1123
+ }
813
1124
 
814
1125
  // src/react/useWebMCP.ts
815
1126
  function isTool(v) {
@@ -817,6 +1128,7 @@ function isTool(v) {
817
1128
  }
818
1129
  function useWebMCP(arg, opts) {
819
1130
  const enabled = opts?.enabled ?? true;
1131
+ const scopedHooks = useWebMCPHooksContext();
820
1132
  const isRawFunction = react.useMemo(() => {
821
1133
  return typeof arg === "function" && !isTool(arg);
822
1134
  }, [arg]);
@@ -838,6 +1150,7 @@ function useWebMCP(arg, opts) {
838
1150
  opts?.global,
839
1151
  opts?.strict,
840
1152
  opts?.outputSchema,
1153
+ opts?.hooks,
841
1154
  opts?.enabled
842
1155
  ]);
843
1156
  const [registered, setRegistered] = react.useState(false);
@@ -850,6 +1163,31 @@ function useWebMCP(arg, opts) {
850
1163
  }, [supported]);
851
1164
  const toolRef = react.useRef(tool);
852
1165
  toolRef.current = tool;
1166
+ react.useEffect(() => {
1167
+ const t = toolRef.current;
1168
+ const hasScoped = scopedHooks && Object.keys(scopedHooks).length > 0;
1169
+ if (hasScoped) t.__scopeHooks = scopedHooks;
1170
+ else if (t.__scopeHooks) delete t.__scopeHooks;
1171
+ return () => {
1172
+ const cur = t.__scopeHooks;
1173
+ if (cur === scopedHooks) {
1174
+ try {
1175
+ delete t.__scopeHooks;
1176
+ } catch {
1177
+ }
1178
+ }
1179
+ };
1180
+ }, [scopedHooks, tool]);
1181
+ if (scopedHooks && Object.keys(scopedHooks).length > 0) {
1182
+ tool.__scopeHooks = scopedHooks;
1183
+ } else if (tool.__scopeHooks) {
1184
+ if (!(scopedHooks && Object.keys(scopedHooks).length > 0)) {
1185
+ try {
1186
+ delete tool.__scopeHooks;
1187
+ } catch {
1188
+ }
1189
+ }
1190
+ }
853
1191
  react.useEffect(() => {
854
1192
  if (!enabled) {
855
1193
  setRegistered(false);
@@ -941,8 +1279,11 @@ function Scope({ tools, enabled = true, children }) {
941
1279
  var WebMCPScope = Scope;
942
1280
 
943
1281
  exports.Scope = Scope;
1282
+ exports.WebMCPHooksContext = WebMCPHooksContext;
1283
+ exports.WebMCPProvider = WebMCPProvider;
944
1284
  exports.WebMCPScope = WebMCPScope;
945
1285
  exports.useTool = useTool;
946
1286
  exports.useWebMCP = useWebMCP;
1287
+ exports.useWebMCPHooksContext = useWebMCPHooksContext;
947
1288
  //# sourceMappingURL=react.cjs.map
948
1289
  //# sourceMappingURL=react.cjs.map