@seed-ship/mcp-ui-solid 6.20.0 → 6.21.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.
Files changed (69) hide show
  1. package/CHANGELOG.md +144 -0
  2. package/README.md +120 -3
  3. package/dist/components/IframeFallbackLink.cjs +37 -0
  4. package/dist/components/IframeFallbackLink.cjs.map +1 -0
  5. package/dist/components/IframeFallbackLink.d.ts +23 -0
  6. package/dist/components/IframeFallbackLink.d.ts.map +1 -0
  7. package/dist/components/IframeFallbackLink.js +37 -0
  8. package/dist/components/IframeFallbackLink.js.map +1 -0
  9. package/dist/components/UIResourceRenderer.cjs +150 -127
  10. package/dist/components/UIResourceRenderer.cjs.map +1 -1
  11. package/dist/components/UIResourceRenderer.d.ts.map +1 -1
  12. package/dist/components/UIResourceRenderer.js +151 -128
  13. package/dist/components/UIResourceRenderer.js.map +1 -1
  14. package/dist/components/VideoRenderer.cjs +45 -19
  15. package/dist/components/VideoRenderer.cjs.map +1 -1
  16. package/dist/components/VideoRenderer.d.ts.map +1 -1
  17. package/dist/components/VideoRenderer.js +45 -19
  18. package/dist/components/VideoRenderer.js.map +1 -1
  19. package/dist/context/MCPUIConfigContext.cjs +29 -0
  20. package/dist/context/MCPUIConfigContext.cjs.map +1 -0
  21. package/dist/context/MCPUIConfigContext.d.ts +124 -0
  22. package/dist/context/MCPUIConfigContext.d.ts.map +1 -0
  23. package/dist/context/MCPUIConfigContext.js +29 -0
  24. package/dist/context/MCPUIConfigContext.js.map +1 -0
  25. package/dist/context/MCPUIStringsContext.cjs +3 -1
  26. package/dist/context/MCPUIStringsContext.cjs.map +1 -1
  27. package/dist/context/MCPUIStringsContext.d.ts +7 -0
  28. package/dist/context/MCPUIStringsContext.d.ts.map +1 -1
  29. package/dist/context/MCPUIStringsContext.js +3 -1
  30. package/dist/context/MCPUIStringsContext.js.map +1 -1
  31. package/dist/index.cjs +6 -0
  32. package/dist/index.cjs.map +1 -1
  33. package/dist/index.d.cts +3 -1
  34. package/dist/index.d.ts +3 -1
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +7 -1
  37. package/dist/index.js.map +1 -1
  38. package/dist/services/index.d.ts +1 -1
  39. package/dist/services/index.d.ts.map +1 -1
  40. package/dist/services/validation.cjs +11 -10
  41. package/dist/services/validation.cjs.map +1 -1
  42. package/dist/services/validation.d.ts +20 -0
  43. package/dist/services/validation.d.ts.map +1 -1
  44. package/dist/services/validation.js +11 -10
  45. package/dist/services/validation.js.map +1 -1
  46. package/dist/utils/iframe-coep.cjs +30 -0
  47. package/dist/utils/iframe-coep.cjs.map +1 -0
  48. package/dist/utils/iframe-coep.d.ts +46 -0
  49. package/dist/utils/iframe-coep.d.ts.map +1 -0
  50. package/dist/utils/iframe-coep.js +30 -0
  51. package/dist/utils/iframe-coep.js.map +1 -0
  52. package/dist/validation.cjs +2 -0
  53. package/dist/validation.cjs.map +1 -1
  54. package/dist/validation.d.cts +1 -1
  55. package/dist/validation.d.ts +1 -1
  56. package/dist/validation.d.ts.map +1 -1
  57. package/dist/validation.js +3 -1
  58. package/package.json +1 -1
  59. package/src/components/IframeFallbackLink.tsx +57 -0
  60. package/src/components/UIResourceRenderer.tsx +31 -4
  61. package/src/components/VideoRenderer.tsx +20 -0
  62. package/src/context/MCPUIConfigContext.tsx +151 -0
  63. package/src/context/MCPUIStringsContext.tsx +10 -0
  64. package/src/index.ts +15 -0
  65. package/src/services/index.ts +1 -0
  66. package/src/services/validation.ts +38 -16
  67. package/src/solid-jsx-attrs.d.ts +24 -0
  68. package/src/utils/iframe-coep.ts +74 -0
  69. package/src/validation.ts +4 -0
@@ -0,0 +1,30 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
3
+ const solidJs = require("solid-js");
4
+ const validation = require("../services/validation.cjs");
5
+ function shouldSetCredentialless(url, config, options) {
6
+ const mode = config.iframeCredentialless;
7
+ if (mode === "always") return true;
8
+ if (mode === "never") return false;
9
+ if (!options.sandboxedWithoutSameOrigin) return false;
10
+ return !validation.isTrustedIframeDomain(url, {
11
+ customTrustedDomains: config.customTrustedIframeDomains
12
+ });
13
+ }
14
+ function createCrossOriginIsolated() {
15
+ const [isolated, setIsolated] = solidJs.createSignal(false);
16
+ solidJs.onMount(() => {
17
+ setIsolated(typeof window !== "undefined" && window.crossOriginIsolated === true);
18
+ });
19
+ return isolated;
20
+ }
21
+ function shouldShowIframeFallbackLink(config, crossOriginIsolated) {
22
+ const mode = config.iframeFallbackLink;
23
+ if (mode === "always") return true;
24
+ if (mode === "never") return false;
25
+ return crossOriginIsolated;
26
+ }
27
+ exports.createCrossOriginIsolated = createCrossOriginIsolated;
28
+ exports.shouldSetCredentialless = shouldSetCredentialless;
29
+ exports.shouldShowIframeFallbackLink = shouldShowIframeFallbackLink;
30
+ //# sourceMappingURL=iframe-coep.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"iframe-coep.cjs","sources":["../../src/utils/iframe-coep.ts"],"sourcesContent":["/**\n * COEP helpers for the two iframes the library renders\n * (`IframeRenderer` in `UIResourceRenderer.tsx`, and the YouTube / Vimeo\n * embed in `VideoRenderer.tsx`).\n *\n * @since v6.21.0\n *\n * A host that serves `Cross-Origin-Embedder-Policy: credentialless` makes the\n * browser refuse any cross-origin iframe whose own document sends no COEP\n * header. The HTML attribute `credentialless` on the `<iframe>` lifts that,\n * at the price of the embedded document losing access to the cookies and\n * storage of its own origin — see `MCPUIConfigContext` for the policy.\n */\n\nimport { createSignal, onMount, type Accessor } from 'solid-js'\nimport { isTrustedIframeDomain } from '../services/validation'\nimport type { MCPUIConfig } from '../context/MCPUIConfigContext'\n\n/**\n * Does this iframe get the boolean `credentialless` attribute?\n *\n * `'auto'` (the default) says yes only when the embed has nothing to lose:\n * the host is not trusted AND the iframe already runs without cookies because\n * its `sandbox` has no `allow-same-origin` (`sandboxedWithoutSameOrigin`).\n * The video embed carries no sandbox, so in `'auto'` it keeps its provider\n * cookies and a COEP host must opt in with `'always'`.\n *\n * An unparsable URL is not trusted, so it gets the attribute.\n */\nexport function shouldSetCredentialless(\n url: string,\n config: MCPUIConfig,\n options: { sandboxedWithoutSameOrigin: boolean }\n): boolean {\n const mode = config.iframeCredentialless\n if (mode === 'always') return true\n if (mode === 'never') return false\n if (!options.sandboxedWithoutSameOrigin) return false\n return !isTrustedIframeDomain(url, {\n customTrustedDomains: config.customTrustedIframeDomains,\n })\n}\n\n/**\n * `window.crossOriginIsolated`, read **after mount** into a signal.\n *\n * Reading it during render would make the server emit markup the client\n * cannot reproduce; the signal starts at `false` (the SSR value) and flips\n * once mounted, so hydration always matches.\n */\nexport function createCrossOriginIsolated(): Accessor<boolean> {\n const [isolated, setIsolated] = createSignal(false)\n onMount(() => {\n setIsolated(typeof window !== 'undefined' && window.crossOriginIsolated === true)\n })\n return isolated\n}\n\n/**\n * Does the \"open in a new tab\" link show under an embed?\n *\n * `'auto'` shows it only on a cross-origin-isolated page — exactly where COEP\n * can block the frame, and where the blocking is silent (a refused\n * cross-origin document fires no `error` event the parent can observe).\n */\nexport function shouldShowIframeFallbackLink(\n config: MCPUIConfig,\n crossOriginIsolated: boolean\n): boolean {\n const mode = config.iframeFallbackLink\n if (mode === 'always') return true\n if (mode === 'never') return false\n return crossOriginIsolated\n}\n"],"names":["isTrustedIframeDomain","createSignal","onMount"],"mappings":";;;;AA6BO,SAAS,wBACd,KACA,QACA,SACS;AACT,QAAM,OAAO,OAAO;AACpB,MAAI,SAAS,SAAU,QAAO;AAC9B,MAAI,SAAS,QAAS,QAAO;AAC7B,MAAI,CAAC,QAAQ,2BAA4B,QAAO;AAChD,SAAO,CAACA,WAAAA,sBAAsB,KAAK;AAAA,IACjC,sBAAsB,OAAO;AAAA,EAAA,CAC9B;AACH;AASO,SAAS,4BAA+C;AAC7D,QAAM,CAAC,UAAU,WAAW,IAAIC,QAAAA,aAAa,KAAK;AAClDC,UAAAA,QAAQ,MAAM;AACZ,gBAAY,OAAO,WAAW,eAAe,OAAO,wBAAwB,IAAI;AAAA,EAClF,CAAC;AACD,SAAO;AACT;AASO,SAAS,6BACd,QACA,qBACS;AACT,QAAM,OAAO,OAAO;AACpB,MAAI,SAAS,SAAU,QAAO;AAC9B,MAAI,SAAS,QAAS,QAAO;AAC7B,SAAO;AACT;;;;"}
@@ -0,0 +1,46 @@
1
+ /**
2
+ * COEP helpers for the two iframes the library renders
3
+ * (`IframeRenderer` in `UIResourceRenderer.tsx`, and the YouTube / Vimeo
4
+ * embed in `VideoRenderer.tsx`).
5
+ *
6
+ * @since v6.21.0
7
+ *
8
+ * A host that serves `Cross-Origin-Embedder-Policy: credentialless` makes the
9
+ * browser refuse any cross-origin iframe whose own document sends no COEP
10
+ * header. The HTML attribute `credentialless` on the `<iframe>` lifts that,
11
+ * at the price of the embedded document losing access to the cookies and
12
+ * storage of its own origin — see `MCPUIConfigContext` for the policy.
13
+ */
14
+ import { type Accessor } from 'solid-js';
15
+ import type { MCPUIConfig } from '../context/MCPUIConfigContext';
16
+ /**
17
+ * Does this iframe get the boolean `credentialless` attribute?
18
+ *
19
+ * `'auto'` (the default) says yes only when the embed has nothing to lose:
20
+ * the host is not trusted AND the iframe already runs without cookies because
21
+ * its `sandbox` has no `allow-same-origin` (`sandboxedWithoutSameOrigin`).
22
+ * The video embed carries no sandbox, so in `'auto'` it keeps its provider
23
+ * cookies and a COEP host must opt in with `'always'`.
24
+ *
25
+ * An unparsable URL is not trusted, so it gets the attribute.
26
+ */
27
+ export declare function shouldSetCredentialless(url: string, config: MCPUIConfig, options: {
28
+ sandboxedWithoutSameOrigin: boolean;
29
+ }): boolean;
30
+ /**
31
+ * `window.crossOriginIsolated`, read **after mount** into a signal.
32
+ *
33
+ * Reading it during render would make the server emit markup the client
34
+ * cannot reproduce; the signal starts at `false` (the SSR value) and flips
35
+ * once mounted, so hydration always matches.
36
+ */
37
+ export declare function createCrossOriginIsolated(): Accessor<boolean>;
38
+ /**
39
+ * Does the "open in a new tab" link show under an embed?
40
+ *
41
+ * `'auto'` shows it only on a cross-origin-isolated page — exactly where COEP
42
+ * can block the frame, and where the blocking is silent (a refused
43
+ * cross-origin document fires no `error` event the parent can observe).
44
+ */
45
+ export declare function shouldShowIframeFallbackLink(config: MCPUIConfig, crossOriginIsolated: boolean): boolean;
46
+ //# sourceMappingURL=iframe-coep.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"iframe-coep.d.ts","sourceRoot":"","sources":["../../src/utils/iframe-coep.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,EAAyB,KAAK,QAAQ,EAAE,MAAM,UAAU,CAAA;AAE/D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,+BAA+B,CAAA;AAEhE;;;;;;;;;;GAUG;AACH,wBAAgB,uBAAuB,CACrC,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,WAAW,EACnB,OAAO,EAAE;IAAE,0BAA0B,EAAE,OAAO,CAAA;CAAE,GAC/C,OAAO,CAQT;AAED;;;;;;GAMG;AACH,wBAAgB,yBAAyB,IAAI,QAAQ,CAAC,OAAO,CAAC,CAM7D;AAED;;;;;;GAMG;AACH,wBAAgB,4BAA4B,CAC1C,MAAM,EAAE,WAAW,EACnB,mBAAmB,EAAE,OAAO,GAC3B,OAAO,CAKT"}
@@ -0,0 +1,30 @@
1
+ import { createSignal, onMount } from "solid-js";
2
+ import { isTrustedIframeDomain } from "../services/validation.js";
3
+ function shouldSetCredentialless(url, config, options) {
4
+ const mode = config.iframeCredentialless;
5
+ if (mode === "always") return true;
6
+ if (mode === "never") return false;
7
+ if (!options.sandboxedWithoutSameOrigin) return false;
8
+ return !isTrustedIframeDomain(url, {
9
+ customTrustedDomains: config.customTrustedIframeDomains
10
+ });
11
+ }
12
+ function createCrossOriginIsolated() {
13
+ const [isolated, setIsolated] = createSignal(false);
14
+ onMount(() => {
15
+ setIsolated(typeof window !== "undefined" && window.crossOriginIsolated === true);
16
+ });
17
+ return isolated;
18
+ }
19
+ function shouldShowIframeFallbackLink(config, crossOriginIsolated) {
20
+ const mode = config.iframeFallbackLink;
21
+ if (mode === "always") return true;
22
+ if (mode === "never") return false;
23
+ return crossOriginIsolated;
24
+ }
25
+ export {
26
+ createCrossOriginIsolated,
27
+ shouldSetCredentialless,
28
+ shouldShowIframeFallbackLink
29
+ };
30
+ //# sourceMappingURL=iframe-coep.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"iframe-coep.js","sources":["../../src/utils/iframe-coep.ts"],"sourcesContent":["/**\n * COEP helpers for the two iframes the library renders\n * (`IframeRenderer` in `UIResourceRenderer.tsx`, and the YouTube / Vimeo\n * embed in `VideoRenderer.tsx`).\n *\n * @since v6.21.0\n *\n * A host that serves `Cross-Origin-Embedder-Policy: credentialless` makes the\n * browser refuse any cross-origin iframe whose own document sends no COEP\n * header. The HTML attribute `credentialless` on the `<iframe>` lifts that,\n * at the price of the embedded document losing access to the cookies and\n * storage of its own origin — see `MCPUIConfigContext` for the policy.\n */\n\nimport { createSignal, onMount, type Accessor } from 'solid-js'\nimport { isTrustedIframeDomain } from '../services/validation'\nimport type { MCPUIConfig } from '../context/MCPUIConfigContext'\n\n/**\n * Does this iframe get the boolean `credentialless` attribute?\n *\n * `'auto'` (the default) says yes only when the embed has nothing to lose:\n * the host is not trusted AND the iframe already runs without cookies because\n * its `sandbox` has no `allow-same-origin` (`sandboxedWithoutSameOrigin`).\n * The video embed carries no sandbox, so in `'auto'` it keeps its provider\n * cookies and a COEP host must opt in with `'always'`.\n *\n * An unparsable URL is not trusted, so it gets the attribute.\n */\nexport function shouldSetCredentialless(\n url: string,\n config: MCPUIConfig,\n options: { sandboxedWithoutSameOrigin: boolean }\n): boolean {\n const mode = config.iframeCredentialless\n if (mode === 'always') return true\n if (mode === 'never') return false\n if (!options.sandboxedWithoutSameOrigin) return false\n return !isTrustedIframeDomain(url, {\n customTrustedDomains: config.customTrustedIframeDomains,\n })\n}\n\n/**\n * `window.crossOriginIsolated`, read **after mount** into a signal.\n *\n * Reading it during render would make the server emit markup the client\n * cannot reproduce; the signal starts at `false` (the SSR value) and flips\n * once mounted, so hydration always matches.\n */\nexport function createCrossOriginIsolated(): Accessor<boolean> {\n const [isolated, setIsolated] = createSignal(false)\n onMount(() => {\n setIsolated(typeof window !== 'undefined' && window.crossOriginIsolated === true)\n })\n return isolated\n}\n\n/**\n * Does the \"open in a new tab\" link show under an embed?\n *\n * `'auto'` shows it only on a cross-origin-isolated page — exactly where COEP\n * can block the frame, and where the blocking is silent (a refused\n * cross-origin document fires no `error` event the parent can observe).\n */\nexport function shouldShowIframeFallbackLink(\n config: MCPUIConfig,\n crossOriginIsolated: boolean\n): boolean {\n const mode = config.iframeFallbackLink\n if (mode === 'always') return true\n if (mode === 'never') return false\n return crossOriginIsolated\n}\n"],"names":[],"mappings":";;AA6BO,SAAS,wBACd,KACA,QACA,SACS;AACT,QAAM,OAAO,OAAO;AACpB,MAAI,SAAS,SAAU,QAAO;AAC9B,MAAI,SAAS,QAAS,QAAO;AAC7B,MAAI,CAAC,QAAQ,2BAA4B,QAAO;AAChD,SAAO,CAAC,sBAAsB,KAAK;AAAA,IACjC,sBAAsB,OAAO;AAAA,EAAA,CAC9B;AACH;AASO,SAAS,4BAA+C;AAC7D,QAAM,CAAC,UAAU,WAAW,IAAI,aAAa,KAAK;AAClD,UAAQ,MAAM;AACZ,gBAAY,OAAO,WAAW,eAAe,OAAO,wBAAwB,IAAI;AAAA,EAClF,CAAC;AACD,SAAO;AACT;AASO,SAAS,6BACd,QACA,qBACS;AACT,QAAM,OAAO,OAAO;AACpB,MAAI,SAAS,SAAU,QAAO;AAC9B,MAAI,SAAS,QAAS,QAAO;AAC7B,SAAO;AACT;"}
@@ -3,6 +3,8 @@ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
3
3
  const validation = require("./services/validation.cjs");
4
4
  exports.DEFAULT_RESOURCE_LIMITS = validation.DEFAULT_RESOURCE_LIMITS;
5
5
  exports.DEFAULT_VALIDATION_MESSAGES = validation.DEFAULT_VALIDATION_MESSAGES;
6
+ exports.getIframeSandbox = validation.getIframeSandbox;
7
+ exports.isTrustedIframeDomain = validation.isTrustedIframeDomain;
6
8
  exports.sanitizeString = validation.sanitizeString;
7
9
  exports.validateChartComponent = validation.validateChartComponent;
8
10
  exports.validateComponent = validation.validateComponent;
@@ -1 +1 @@
1
- {"version":3,"file":"validation.cjs","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;;;;;"}
1
+ {"version":3,"file":"validation.cjs","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;;;;;;;"}
@@ -9,7 +9,7 @@
9
9
  * import { validateLayout } from '@seed-ship/mcp-ui-solid/validation'
10
10
  * ```
11
11
  */
12
- export { validateComponent, validateLayout, validateGridPosition, validateChartComponent, validateTableComponent, validatePayloadSize, validateIframeDomain, sanitizeString, DEFAULT_RESOURCE_LIMITS, validateFieldValue, validateFormData, DEFAULT_VALIDATION_MESSAGES, } from './services/validation';
12
+ export { validateComponent, validateLayout, validateGridPosition, validateChartComponent, validateTableComponent, validatePayloadSize, validateIframeDomain, getIframeSandbox, isTrustedIframeDomain, sanitizeString, DEFAULT_RESOURCE_LIMITS, validateFieldValue, validateFormData, DEFAULT_VALIDATION_MESSAGES, } from './services/validation';
13
13
  export type { FormValidationMessages } from './services/validation';
14
14
  export type { UIComponent, UILayout, GridPosition, ComponentType, ChartComponentParams, TableComponentParams, MetricComponentParams, TextComponentParams, ValidationResult, ResourceLimits, } from './types';
15
15
  //# sourceMappingURL=validation.d.ts.map
@@ -9,7 +9,7 @@
9
9
  * import { validateLayout } from '@seed-ship/mcp-ui-solid/validation'
10
10
  * ```
11
11
  */
12
- export { validateComponent, validateLayout, validateGridPosition, validateChartComponent, validateTableComponent, validatePayloadSize, validateIframeDomain, sanitizeString, DEFAULT_RESOURCE_LIMITS, validateFieldValue, validateFormData, DEFAULT_VALIDATION_MESSAGES, } from './services/validation';
12
+ export { validateComponent, validateLayout, validateGridPosition, validateChartComponent, validateTableComponent, validatePayloadSize, validateIframeDomain, getIframeSandbox, isTrustedIframeDomain, sanitizeString, DEFAULT_RESOURCE_LIMITS, validateFieldValue, validateFormData, DEFAULT_VALIDATION_MESSAGES, } from './services/validation';
13
13
  export type { FormValidationMessages } from './services/validation';
14
14
  export type { UIComponent, UILayout, GridPosition, ComponentType, ChartComponentParams, TableComponentParams, MetricComponentParams, TextComponentParams, ValidationResult, ResourceLimits, } from './types';
15
15
  //# sourceMappingURL=validation.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"validation.d.ts","sourceRoot":"","sources":["../src/validation.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAGH,OAAO,EACL,iBAAiB,EACjB,cAAc,EACd,oBAAoB,EACpB,sBAAsB,EACtB,sBAAsB,EACtB,mBAAmB,EACnB,oBAAoB,EACpB,cAAc,EACd,uBAAuB,EAEvB,kBAAkB,EAClB,gBAAgB,EAChB,2BAA2B,GAC5B,MAAM,uBAAuB,CAAA;AAC9B,YAAY,EAAE,sBAAsB,EAAE,MAAM,uBAAuB,CAAA;AAGnE,YAAY,EACV,WAAW,EACX,QAAQ,EACR,YAAY,EACZ,aAAa,EACb,oBAAoB,EACpB,oBAAoB,EACpB,qBAAqB,EACrB,mBAAmB,EACnB,gBAAgB,EAChB,cAAc,GACf,MAAM,SAAS,CAAA"}
1
+ {"version":3,"file":"validation.d.ts","sourceRoot":"","sources":["../src/validation.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAGH,OAAO,EACL,iBAAiB,EACjB,cAAc,EACd,oBAAoB,EACpB,sBAAsB,EACtB,sBAAsB,EACtB,mBAAmB,EACnB,oBAAoB,EACpB,gBAAgB,EAGhB,qBAAqB,EACrB,cAAc,EACd,uBAAuB,EAEvB,kBAAkB,EAClB,gBAAgB,EAChB,2BAA2B,GAC5B,MAAM,uBAAuB,CAAA;AAC9B,YAAY,EAAE,sBAAsB,EAAE,MAAM,uBAAuB,CAAA;AAGnE,YAAY,EACV,WAAW,EACX,QAAQ,EACR,YAAY,EACZ,aAAa,EACb,oBAAoB,EACpB,oBAAoB,EACpB,qBAAqB,EACrB,mBAAmB,EACnB,gBAAgB,EAChB,cAAc,GACf,MAAM,SAAS,CAAA"}
@@ -1,7 +1,9 @@
1
- import { DEFAULT_RESOURCE_LIMITS, DEFAULT_VALIDATION_MESSAGES, sanitizeString, validateChartComponent, validateComponent, validateFieldValue, validateFormData, validateGridPosition, validateIframeDomain, validateLayout, validatePayloadSize, validateTableComponent } from "./services/validation.js";
1
+ import { DEFAULT_RESOURCE_LIMITS, DEFAULT_VALIDATION_MESSAGES, getIframeSandbox, isTrustedIframeDomain, sanitizeString, validateChartComponent, validateComponent, validateFieldValue, validateFormData, validateGridPosition, validateIframeDomain, validateLayout, validatePayloadSize, validateTableComponent } from "./services/validation.js";
2
2
  export {
3
3
  DEFAULT_RESOURCE_LIMITS,
4
4
  DEFAULT_VALIDATION_MESSAGES,
5
+ getIframeSandbox,
6
+ isTrustedIframeDomain,
5
7
  sanitizeString,
6
8
  validateChartComponent,
7
9
  validateComponent,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seed-ship/mcp-ui-solid",
3
- "version": "6.20.0",
3
+ "version": "6.21.0",
4
4
  "description": "SolidJS components for rendering MCP-generated UI resources",
5
5
  "type": "module",
6
6
  "files": [
@@ -0,0 +1,57 @@
1
+ /**
2
+ * IframeFallbackLink — the way out of a silently blocked embed.
3
+ *
4
+ * @since v6.21.0
5
+ *
6
+ * An iframe refused by `Cross-Origin-Embedder-Policy` fires no event the
7
+ * parent page can observe (`onerror` does not fire for a rejected
8
+ * cross-origin document), so the user is left with a blank frame and no
9
+ * explanation. This link, rendered under the frame, always works.
10
+ *
11
+ * Visibility follows `MCPUIConfig.iframeFallbackLink`; `'auto'` (the default)
12
+ * shows it only on a cross-origin-isolated page. The URL goes through
13
+ * `safeUrl` — an unsafe one renders nothing at all.
14
+ */
15
+
16
+ import { createMemo, Show, type JSX } from 'solid-js'
17
+ import { safeUrl } from '../utils/safe-url'
18
+ import { useMCPUIStrings } from '../context/MCPUIStringsContext'
19
+ import { useMCPUIConfig } from '../context/MCPUIConfigContext'
20
+ import {
21
+ createCrossOriginIsolated,
22
+ shouldShowIframeFallbackLink,
23
+ } from '../utils/iframe-coep'
24
+
25
+ export interface IframeFallbackLinkProps {
26
+ /** The embedded URL. Bound to `href` only when `safeUrl` accepts it. */
27
+ url: string
28
+ /** Extra classes on the wrapping `<div>`. */
29
+ class?: string
30
+ }
31
+
32
+ export function IframeFallbackLink(props: IframeFallbackLinkProps): JSX.Element {
33
+ const strings = useMCPUIStrings()
34
+ const config = useMCPUIConfig()
35
+ const crossOriginIsolated = createCrossOriginIsolated()
36
+
37
+ const href = createMemo(() => safeUrl(props.url))
38
+ const visible = createMemo(
39
+ () => !!href() && shouldShowIframeFallbackLink(config, crossOriginIsolated())
40
+ )
41
+
42
+ return (
43
+ <Show when={visible()}>
44
+ <div class={`px-4 py-2 text-right ${props.class || ''}`}>
45
+ <a
46
+ href={href()}
47
+ target="_blank"
48
+ rel="noopener noreferrer"
49
+ data-mcp-ui-action="open-external"
50
+ class="text-xs text-blue-600 dark:text-blue-400 hover:underline focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 rounded"
51
+ >
52
+ {strings.iframeOpenInNewTab}
53
+ </a>
54
+ </div>
55
+ </Show>
56
+ )
57
+ }
@@ -10,7 +10,7 @@ import { escapeHtml } from '../utils/escape-html'
10
10
  import { safeUrl } from '../utils/safe-url'
11
11
  import { SafeHtml } from './SafeHtml'
12
12
  import type { UIComponent, UILayout, RendererError, TableVirtualizeOptions } from '../types'
13
- import { validateComponent, DEFAULT_RESOURCE_LIMITS, getIframeSandbox } from '../services/validation'
13
+ import { validateComponent, DEFAULT_RESOURCE_LIMITS, getIframeSandbox, isTrustedIframeDomain } from '../services/validation'
14
14
  import { GenerativeUIErrorBoundary } from './GenerativeUIErrorBoundary'
15
15
  import { markRenderStart, markRenderEnd, PERF_PREFIX } from '../utils/perf'
16
16
  import { isDebugEnabled } from '../utils/logger'
@@ -27,6 +27,9 @@ import {
27
27
  formatMCPUIString,
28
28
  useMCPUIStrings,
29
29
  } from '../context/MCPUIStringsContext'
30
+ import { useMCPUIConfig } from '../context/MCPUIConfigContext'
31
+ import { shouldSetCredentialless } from '../utils/iframe-coep'
32
+ import { IframeFallbackLink } from './IframeFallbackLink'
30
33
 
31
34
  /**
32
35
  * How `<UIResourceRenderer>` reacts when `validateComponent()` rejects a
@@ -1458,7 +1461,12 @@ function TextRenderer(props: { component: UIComponent }) {
1458
1461
  */
1459
1462
  function IframeRenderer(props: { component: UIComponent }) {
1460
1463
  const strings = useMCPUIStrings()
1464
+ const config = useMCPUIConfig()
1461
1465
  const params = props.component.params as any
1466
+ // v6.21.0 — the host's custom trusted hosts now reach BOTH the sandbox (they
1467
+ // used not to: `getIframeSandbox(params.url)` was called with no options) and
1468
+ // the `credentialless` decision.
1469
+ const trustOptions = () => ({ customTrustedDomains: config.customTrustedIframeDomains })
1462
1470
  return (
1463
1471
  <div class="w-full h-full bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden flex flex-col">
1464
1472
  <Show when={params.title}>
@@ -1471,9 +1479,22 @@ function IframeRenderer(props: { component: UIComponent }) {
1471
1479
  title={params.title || strings.iframeTitle}
1472
1480
  class="w-full border-0 flex-1"
1473
1481
  style={`height: ${params.height || '400px'}; min-height: 300px;`}
1474
- sandbox={getIframeSandbox(params.url)}
1482
+ sandbox={getIframeSandbox(params.url, trustOptions())}
1483
+ // `attr:` is required: `credentialless` is in dom-expressions'
1484
+ // `Properties` set, so a plain `credentialless={…}` compiles to a JS
1485
+ // property assignment and never reaches the DOM as an attribute.
1486
+ // `true | undefined` keeps it a BOOLEAN attribute — bare in SSR
1487
+ // markup, removed (not `="false"`) when it does not apply.
1488
+ attr:credentialless={
1489
+ shouldSetCredentialless(params.url, config, {
1490
+ // This iframe's sandbox never carries `allow-same-origin` for an
1491
+ // untrusted host, so it already runs without cookies.
1492
+ sandboxedWithoutSameOrigin: !isTrustedIframeDomain(params.url, trustOptions()),
1493
+ }) || undefined
1494
+ }
1475
1495
  loading="lazy"
1476
1496
  />
1497
+ <IframeFallbackLink url={params.url} class="border-t border-gray-200 dark:border-gray-700" />
1477
1498
  </div>
1478
1499
  )
1479
1500
  }
@@ -1595,6 +1616,7 @@ function ComponentRenderer(props: {
1595
1616
  // opt in see zero behavior change.
1596
1617
  const telemetry = useTelemetry()
1597
1618
  const strings = useMCPUIStrings()
1619
+ const componentConfig = useMCPUIConfig()
1598
1620
 
1599
1621
  onMount(() => {
1600
1622
  markRenderEnd(props.component.id)
@@ -1636,8 +1658,13 @@ function ComponentRenderer(props: {
1636
1658
  }
1637
1659
  })
1638
1660
 
1639
- // Validate component before rendering
1640
- const validation = validateComponent(props.component)
1661
+ // Validate component before rendering. The host's allow-list extension
1662
+ // (v6.21.0) has to reach validation too: an unlisted host is replaced by the
1663
+ // validation card before any renderer runs.
1664
+ const validation = validateComponent(props.component, {
1665
+ iframePolicy: componentConfig.iframePolicy,
1666
+ customIframeDomains: componentConfig.customIframeDomains,
1667
+ })
1641
1668
  if (!validation.valid) {
1642
1669
  props.onError?.({
1643
1670
  type: 'validation',
@@ -9,6 +9,9 @@ import { Component, createMemo, Show } from 'solid-js'
9
9
  import type { UIComponent, VideoComponentParams } from '../types'
10
10
  import { ExpandableWrapper, useExpanded } from './ExpandableWrapper'
11
11
  import { useMCPUIStrings } from '../context/MCPUIStringsContext'
12
+ import { useMCPUIConfig } from '../context/MCPUIConfigContext'
13
+ import { shouldSetCredentialless } from '../utils/iframe-coep'
14
+ import { IframeFallbackLink } from './IframeFallbackLink'
12
15
 
13
16
  export interface VideoRendererProps {
14
17
  /**
@@ -76,6 +79,7 @@ function parseVideoUrl(url: string): VideoInfo {
76
79
 
77
80
  export const VideoRenderer: Component<VideoRendererProps> = (props) => {
78
81
  const strings = useMCPUIStrings()
82
+ const config = useMCPUIConfig()
79
83
  const params = () => props.params || (props.component?.params as VideoComponentParams)
80
84
  const isExpanded = useExpanded()
81
85
 
@@ -181,11 +185,27 @@ export const VideoRenderer: Component<VideoRendererProps> = (props) => {
181
185
  class="absolute inset-0 w-full h-full"
182
186
  allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
183
187
  allowfullscreen
188
+ // v6.21.0 — youtube-nocookie / player.vimeo are not trusted hosts,
189
+ // so under COEP `credentialless` is what lets them load at all.
190
+ // `attr:` + `true | undefined` — see the note in `IframeRenderer`.
191
+ attr:credentialless={
192
+ // No sandbox here: a YouTube/Vimeo embed keeps its provider
193
+ // cookies, so `'auto'` leaves it alone and a COEP host opts in
194
+ // with `iframeCredentialless: 'always'`.
195
+ shouldSetCredentialless(embedUrl()!, config, {
196
+ sandboxedWithoutSameOrigin: false,
197
+ }) || undefined
198
+ }
184
199
  loading="lazy"
185
200
  />
186
201
  </Show>
187
202
  </div>
188
203
 
204
+ {/* v6.21.0 — a COEP-blocked embed is silent; this link is the way out. */}
205
+ <Show when={embedUrl()}>
206
+ <IframeFallbackLink url={params()?.url || ''} />
207
+ </Show>
208
+
189
209
  {/* Caption */}
190
210
  <Show when={params()?.caption}>
191
211
  <div class="px-4 py-3 border-t border-gray-200 dark:border-gray-700 flex-shrink-0">
@@ -0,0 +1,151 @@
1
+ /**
2
+ * MCPUIConfigContext — host-level rendering policy for the library's iframes.
3
+ *
4
+ * @since v6.21.0
5
+ *
6
+ * ## Why
7
+ *
8
+ * A host that serves `Cross-Origin-Embedder-Policy: credentialless` makes the
9
+ * browser refuse every cross-origin iframe whose own document does not send a
10
+ * COEP header — which is YouTube, Vimeo, Google Docs, Notion and nearly all of
11
+ * `DEFAULT_IFRAME_DOMAINS`. The escape hatch is the boolean HTML attribute
12
+ * `credentialless` on the `<iframe>` itself: the document then loads, but
13
+ * without access to the cookies and storage of its own origin.
14
+ *
15
+ * That trade-off is free for a public video or chart and fatal for an
16
+ * authenticated embed, so the library keys it on the distinction it already
17
+ * makes: `TRUSTED_IFRAME_DOMAINS` (the hosts that receive `allow-same-origin`
18
+ * because they need their own cookies) do NOT get the attribute; everything
19
+ * else does. This context lets a host override that, add its own trusted
20
+ * hosts, and control the "open in a new tab" fallback link.
21
+ *
22
+ * ## Scope — policy only, NOT strings
23
+ *
24
+ * Text lives in `MCPUIStrings` (`MCPUIStringsContext`). This context carries
25
+ * only behavioural switches, and like the strings context every renderer works
26
+ * standalone: with no provider mounted, `DEFAULT_MCPUI_CONFIG` applies.
27
+ *
28
+ * @example
29
+ * ```tsx
30
+ * import { MCPUIConfigProvider } from '@seed-ship/mcp-ui-solid'
31
+ *
32
+ * <MCPUIConfigProvider config={{ customTrustedIframeDomains: ['embed.acme.com'] }}>
33
+ * <App />
34
+ * </MCPUIConfigProvider>
35
+ * ```
36
+ */
37
+
38
+ import { createContext, mergeProps, useContext, type JSX } from 'solid-js'
39
+ import type { IframePolicy } from '../types'
40
+
41
+ /**
42
+ * Host-level rendering policy. Every field has a default — a provider passes
43
+ * a partial override.
44
+ */
45
+ export interface MCPUIConfig {
46
+ /**
47
+ * When the library puts the boolean `credentialless` attribute on an
48
+ * `<iframe>` it renders.
49
+ *
50
+ * - `'auto'` (default) — on every iframe whose host is NOT in
51
+ * `TRUSTED_IFRAME_DOMAINS` (plus `customTrustedIframeDomains`). Those
52
+ * hosts already ran without cookies, since their sandbox has no
53
+ * `allow-same-origin`; the attribute is what unblocks them under COEP.
54
+ * - `'always'` — on every iframe, trusted hosts included. An authenticated
55
+ * embed will then render its login screen.
56
+ * - `'never'` — never. The right value for a host that sends no COEP header
57
+ * and does not want its embeds to lose their cookies.
58
+ *
59
+ * Chrome/Edge 110+ honour the attribute; Firefox ignores it (iframes stay
60
+ * blocked while the host sends COEP); Safari ignores the COEP value itself,
61
+ * so nothing is blocked there in the first place.
62
+ */
63
+ iframeCredentialless: 'auto' | 'always' | 'never'
64
+
65
+ /**
66
+ * Extra hosts treated as trusted, on top of `TRUSTED_IFRAME_DOMAINS`:
67
+ * no `credentialless` attribute, and `allow-same-origin` in the sandbox.
68
+ * A subdomain of a listed host matches too.
69
+ *
70
+ * This only RECLASSIFIES a host the allow-list already accepts. To render an
71
+ * iframe whose host is outside `DEFAULT_IFRAME_DOMAINS`, add it to
72
+ * {@link MCPUIConfig.customIframeDomains} with `iframePolicy: 'extend'`.
73
+ */
74
+ customTrustedIframeDomains: string[]
75
+
76
+ /**
77
+ * How `UIResourceRenderer` validates an `iframe` component's host:
78
+ * `'strict'` (default) accepts `DEFAULT_IFRAME_DOMAINS` only, `'extend'`
79
+ * also accepts {@link MCPUIConfig.customIframeDomains}.
80
+ */
81
+ iframePolicy: IframePolicy
82
+
83
+ /**
84
+ * Extra hosts the allow-list accepts when `iframePolicy` is `'extend'`.
85
+ * Without this, a component pointing at an unlisted host is replaced by the
86
+ * validation card before any renderer runs.
87
+ */
88
+ customIframeDomains: string[]
89
+
90
+ /**
91
+ * When the library renders an "open in a new tab" link under an embed
92
+ * (`strings.iframeOpenInNewTab`).
93
+ *
94
+ * A COEP-blocked iframe fires no event the parent page can observe, so
95
+ * without this the user sees an unexplained blank frame.
96
+ *
97
+ * - `'auto'` (default) — only when the page is cross-origin isolated
98
+ * (`window.crossOriginIsolated`). That flag needs COOP `same-origin` AND
99
+ * COEP, so a host that sends COEP alone still blocks embeds while the
100
+ * flag is false: set `'always'` there. Read after mount, never during
101
+ * render, so SSR markup and hydration agree.
102
+ * - `'always'` / `'never'` — unconditional.
103
+ */
104
+ iframeFallbackLink: 'auto' | 'always' | 'never'
105
+ }
106
+
107
+ /**
108
+ * Defaults. Chosen so a host that sends no COEP header sees no change from
109
+ * 6.20.0 apart from the fallback link, which `'auto'` keeps hidden there.
110
+ */
111
+ export const DEFAULT_MCPUI_CONFIG: MCPUIConfig = {
112
+ iframeCredentialless: 'auto',
113
+ customTrustedIframeDomains: [],
114
+ iframePolicy: 'strict',
115
+ customIframeDomains: [],
116
+ iframeFallbackLink: 'auto',
117
+ }
118
+
119
+ export const MCPUIConfigContext = createContext<MCPUIConfig>(DEFAULT_MCPUI_CONFIG)
120
+
121
+ /**
122
+ * Reads the active host config. Returns `DEFAULT_MCPUI_CONFIG` when no
123
+ * `<MCPUIConfigProvider>` is mounted above.
124
+ */
125
+ export function useMCPUIConfig(): MCPUIConfig {
126
+ return mergeProps(DEFAULT_MCPUI_CONFIG, useContext(MCPUIConfigContext))
127
+ }
128
+
129
+ export interface MCPUIConfigProviderProps {
130
+ /**
131
+ * Partial override of the host config. Any key left unset falls back to
132
+ * `DEFAULT_MCPUI_CONFIG`.
133
+ */
134
+ config?: Partial<MCPUIConfig>
135
+ children: JSX.Element
136
+ }
137
+
138
+ /**
139
+ * Provides the host config to every MCP-UI renderer below it.
140
+ * Merges the partial `config` override over the defaults.
141
+ */
142
+ export function MCPUIConfigProvider(props: MCPUIConfigProviderProps): JSX.Element {
143
+ // The function source keeps replacement `config` objects reactive while
144
+ // mergeProps fills any omitted key without taking a one-time snapshot.
145
+ const value = mergeProps(DEFAULT_MCPUI_CONFIG, () => props.config)
146
+ return (
147
+ <MCPUIConfigContext.Provider value={value}>
148
+ {props.children}
149
+ </MCPUIConfigContext.Provider>
150
+ )
151
+ }
@@ -323,6 +323,13 @@ export interface MCPUIStrings {
323
323
  imageViewFullSize?: string
324
324
  /** Fallback `title` of an iframe without one. */
325
325
  iframeTitle?: string
326
+ /**
327
+ * Label of the "open in a new tab" link rendered under an embed
328
+ * (`IframeRenderer`, `VideoRenderer`). A COEP-blocked iframe fires no
329
+ * observable event, so this link is the only way out of a blank frame.
330
+ * @since 6.21.0
331
+ */
332
+ iframeOpenInNewTab?: string
326
333
  /** Fallback label of a link component without one. */
327
334
  linkLabel?: string
328
335
  /**
@@ -1085,6 +1092,9 @@ export const DEFAULT_MCPUI_STRINGS: Required<MCPUIStrings> = {
1085
1092
  sizeBytes: '{size} B',
1086
1093
  sizeKilobytes: '{size} KB',
1087
1094
  sizeMegabytes: '{size} MB',
1095
+
1096
+ // ── 6.21.0 — iframe COEP fallback ──────────────────────────
1097
+ iframeOpenInNewTab: 'Open in a new tab',
1088
1098
  }
1089
1099
 
1090
1100
  /**
package/src/index.ts CHANGED
@@ -142,6 +142,18 @@ export type {
142
142
  MCPUIStringsProviderProps,
143
143
  } from './context/MCPUIStringsContext'
144
144
 
145
+ // Host rendering policy — iframe `credentialless` / COEP (v6.21.0)
146
+ export {
147
+ MCPUIConfigProvider,
148
+ MCPUIConfigContext,
149
+ useMCPUIConfig,
150
+ DEFAULT_MCPUI_CONFIG,
151
+ } from './context/MCPUIConfigContext'
152
+ export type {
153
+ MCPUIConfig,
154
+ MCPUIConfigProviderProps,
155
+ } from './context/MCPUIConfigContext'
156
+
145
157
  // Telemetry sink (B.5 — v5.6.0)
146
158
  export {
147
159
  MCPUITelemetryProvider,
@@ -331,6 +343,9 @@ export {
331
343
  validateLayout,
332
344
  validateIframeDomain,
333
345
  getIframeSandbox,
346
+ // v6.21.0 — the trusted-host test behind `getIframeSandbox` and the
347
+ // `credentialless` iframe attribute.
348
+ isTrustedIframeDomain,
334
349
  DEFAULT_RESOURCE_LIMITS,
335
350
  DEFAULT_IFRAME_DOMAINS,
336
351
  TRUSTED_IFRAME_DOMAINS,
@@ -9,6 +9,7 @@ export {
9
9
  validateLayout,
10
10
  validateIframeDomain,
11
11
  getIframeSandbox,
12
+ isTrustedIframeDomain,
12
13
  DEFAULT_RESOURCE_LIMITS,
13
14
  DEFAULT_IFRAME_DOMAINS,
14
15
  TRUSTED_IFRAME_DOMAINS,