@ai-sdk/react
Advanced tools
+12
-1
@@ -231,2 +231,7 @@ import { FlexibleSchema, FetchFunction, Resolvable, InferSchema } from '@ai-sdk/provider-utils'; | ||
| /** | ||
| * A permission an MCP App may request and a host may allow. | ||
| */ | ||
| type MCPAppPermission = 'camera' | 'microphone' | 'geolocation' | 'clipboardWrite'; | ||
| type MCPAppDisplayMode = 'inline' | 'fullscreen' | 'pip'; | ||
@@ -289,2 +294,8 @@ type MCPAppMetadata = { | ||
| innerSandbox?: string; | ||
| /** | ||
| * Iframe capabilities the host allows an app to use. Deny-by-default: a | ||
| * server-requested permission (camera/microphone/geolocation/clipboardWrite) | ||
| * is only granted when it also appears here. Omitting this grants nothing. | ||
| */ | ||
| allowedPermissions?: MCPAppPermission[]; | ||
| }; | ||
@@ -320,2 +331,2 @@ type MCPAppRendererProps = { | ||
| export { Chat, Experimental_UseObjectHelpers, Experimental_UseObjectOptions, UseRealtimeOptions as Experimental_UseRealtimeOptions, UseRealtimeReturn as Experimental_UseRealtimeReturn, MCPAppBridgeHandlers, MCPAppMetadata, MCPAppRendererProps, MCPAppSandboxConfig, UseChatHelpers, UseChatOptions, UseCompletionHelpers, UseObjectHelpers, UseObjectOptions, MCPAppRenderer as experimental_MCPAppRenderer, experimental_useObject, experimental_useRealtime, useChat, useCompletion, useObject }; | ||
| export { Chat, Experimental_UseObjectHelpers, Experimental_UseObjectOptions, UseRealtimeOptions as Experimental_UseRealtimeOptions, UseRealtimeReturn as Experimental_UseRealtimeReturn, MCPAppBridgeHandlers, MCPAppMetadata, MCPAppPermission, MCPAppRendererProps, MCPAppSandboxConfig, UseChatHelpers, UseChatOptions, UseCompletionHelpers, UseObjectHelpers, UseObjectOptions, MCPAppRenderer as experimental_MCPAppRenderer, experimental_useObject, experimental_useRealtime, useChat, useCompletion, useObject }; |
+74
-18
@@ -716,2 +716,34 @@ var __accessCheck = (obj, member, msg) => { | ||
| } | ||
| function assertResourceReadParams(params) { | ||
| if (!isJSONObject(params) || typeof params.uri !== "string") { | ||
| throw new Error("Invalid resources/read params"); | ||
| } | ||
| if (!params.uri.startsWith("ui://")) { | ||
| throw new Error( | ||
| `resources/read is limited to ui:// resources: ${params.uri}` | ||
| ); | ||
| } | ||
| return { uri: params.uri }; | ||
| } | ||
| function assertOpenLinkParams(params) { | ||
| if (!isJSONObject(params) || typeof params.url !== "string") { | ||
| throw new Error("Invalid ui/open-link params"); | ||
| } | ||
| let scheme; | ||
| try { | ||
| scheme = new URL(params.url).protocol; | ||
| } catch (e) { | ||
| throw new Error(`Invalid ui/open-link url: ${params.url}`); | ||
| } | ||
| if (scheme !== "https:" && scheme !== "http:" && scheme !== "mailto:") { | ||
| throw new Error(`Disallowed ui/open-link scheme: ${scheme}`); | ||
| } | ||
| return { url: params.url }; | ||
| } | ||
| function assertDisplayModeParams(params) { | ||
| if (!isJSONObject(params) || params.mode !== "inline" && params.mode !== "fullscreen" && params.mode !== "pip") { | ||
| throw new Error("Invalid ui/request-display-mode params"); | ||
| } | ||
| return { mode: params.mode }; | ||
| } | ||
| var MCPAppBridge = class { | ||
@@ -757,6 +789,14 @@ constructor({ | ||
| /** | ||
| * Whether a `message` event came from the expected proxy window and origin. | ||
| * The origin check is skipped only when `targetOrigin` is the `'*'` default. | ||
| * Callers that intercept events before {@link handleMessage} share this check. | ||
| */ | ||
| acceptsEvent(event) { | ||
| return event.source === this.targetWindow && (this.targetOrigin === "*" || event.origin === this.targetOrigin); | ||
| } | ||
| /** | ||
| * Processes one `message` event from the sandbox proxy iframe. | ||
| */ | ||
| handleMessage(event) { | ||
| if (event.source !== this.targetWindow || !isJsonRpcMessage(event.data)) { | ||
| if (!this.acceptsEvent(event) || !isJsonRpcMessage(event.data)) { | ||
| return; | ||
@@ -894,3 +934,5 @@ } | ||
| } | ||
| return this.handlers.readResource(request.params); | ||
| return this.handlers.readResource( | ||
| assertResourceReadParams(request.params) | ||
| ); | ||
| case "resources/list": | ||
@@ -905,3 +947,3 @@ if (this.handlers.listResources == null) { | ||
| } | ||
| return this.handlers.openLink(request.params); | ||
| return this.handlers.openLink(assertOpenLinkParams(request.params)); | ||
| case "ui/message": | ||
@@ -914,3 +956,3 @@ return (_c = (_b = (_a = this.handlers).sendMessage) == null ? void 0 : _b.call(_a, request.params)) != null ? _c : {}; | ||
| _g, | ||
| request.params | ||
| assertDisplayModeParams(request.params) | ||
| )) != null ? _j : { mode: (_i = this.hostContext.displayMode) != null ? _i : "inline" }; | ||
@@ -1035,15 +1077,13 @@ default: | ||
| } | ||
| function getMCPAppAllowAttribute(permissions) { | ||
| if (permissions == null) { | ||
| var MCP_APP_PERMISSION_FEATURES = { | ||
| camera: "camera", | ||
| microphone: "microphone", | ||
| geolocation: "geolocation", | ||
| clipboardWrite: "clipboard-write" | ||
| }; | ||
| function getMCPAppAllowAttribute(permissions, allowedPermissions) { | ||
| if (permissions == null || allowedPermissions == null) { | ||
| return void 0; | ||
| } | ||
| const allow = []; | ||
| if (permissions.camera) | ||
| allow.push("camera"); | ||
| if (permissions.microphone) | ||
| allow.push("microphone"); | ||
| if (permissions.geolocation) | ||
| allow.push("geolocation"); | ||
| if (permissions.clipboardWrite) | ||
| allow.push("clipboard-write"); | ||
| const allow = allowedPermissions.filter((permission) => Boolean(permissions[permission])).map((permission) => MCP_APP_PERMISSION_FEATURES[permission]); | ||
| return allow.length > 0 ? allow.join("; ") : void 0; | ||
@@ -1077,2 +1117,11 @@ } | ||
| import { jsx } from "react/jsx-runtime"; | ||
| function deriveTargetOrigin(url) { | ||
| var _a; | ||
| const location = typeof window !== "undefined" ? window.location : void 0; | ||
| try { | ||
| return new URL(url, location == null ? void 0 : location.href).origin; | ||
| } catch (e) { | ||
| return (_a = location == null ? void 0 : location.origin) != null ? _a : "null"; | ||
| } | ||
| } | ||
| function sendToolState({ | ||
@@ -1113,6 +1162,9 @@ bridge, | ||
| hostContextRef.current = hostContext; | ||
| const targetOrigin = (_a = sandbox.targetOrigin) != null ? _a : "*"; | ||
| const sandboxUrl = String(sandbox.url); | ||
| const targetOrigin = (_a = sandbox.targetOrigin) != null ? _a : deriveTargetOrigin(sandboxUrl); | ||
| const resourceCSP = getMCPAppCSP((_b = resource.meta) == null ? void 0 : _b.csp); | ||
| const resourceAllow = getMCPAppAllowAttribute((_c = resource.meta) == null ? void 0 : _c.permissions); | ||
| const resourceAllow = getMCPAppAllowAttribute( | ||
| (_c = resource.meta) == null ? void 0 : _c.permissions, | ||
| sandbox.allowedPermissions | ||
| ); | ||
| const innerSandbox = (_d = sandbox.innerSandbox) != null ? _d : MCP_APP_DEFAULT_INNER_SANDBOX; | ||
@@ -1154,3 +1206,6 @@ const bridgeHandlers = useMemo( | ||
| var _a2; | ||
| if (event.source === targetWindow && ((_a2 = event.data) == null ? void 0 : _a2.jsonrpc) === "2.0" && event.data.method === "ui/notifications/sandbox-proxy-ready") { | ||
| if (!bridge.acceptsEvent(event)) { | ||
| return; | ||
| } | ||
| if (((_a2 = event.data) == null ? void 0 : _a2.jsonrpc) === "2.0" && event.data.method === "ui/notifications/sandbox-proxy-ready") { | ||
| bridge.sendSandboxResourceReady({ | ||
@@ -1215,2 +1270,3 @@ html: resource.html, | ||
| style: sandbox.style, | ||
| allow: resourceAllow, | ||
| sandbox: (_f = sandbox.outerSandbox) != null ? _f : MCP_APP_DEFAULT_OUTER_SANDBOX | ||
@@ -1217,0 +1273,0 @@ } |
+4
-4
| { | ||
| "name": "@ai-sdk/react", | ||
| "version": "4.0.30", | ||
| "version": "4.0.31", | ||
| "type": "module", | ||
@@ -31,6 +31,6 @@ "license": "Apache-2.0", | ||
| "throttleit": "2.1.0", | ||
| "@ai-sdk/mcp": "2.0.13", | ||
| "ai": "7.0.28", | ||
| "@ai-sdk/mcp": "2.0.14", | ||
| "@ai-sdk/provider": "4.0.3", | ||
| "@ai-sdk/provider-utils": "5.0.10" | ||
| "@ai-sdk/provider-utils": "5.0.10", | ||
| "ai": "7.0.28" | ||
| }, | ||
@@ -37,0 +37,0 @@ "devDependencies": { |
@@ -12,2 +12,18 @@ import { useEffect, useMemo, useRef } from 'react'; | ||
| /** | ||
| * Derives the concrete origin of the sandbox proxy from its URL, so outbound | ||
| * postMessage targets a specific origin instead of `'*'` and inbound messages | ||
| * can be origin-checked. The proxy must be served from a stable, concrete | ||
| * origin (the default outer sandbox keeps `allow-same-origin`). Falls back to | ||
| * the host origin on a malformed URL, never `'*'`. | ||
| */ | ||
| function deriveTargetOrigin(url: string): string { | ||
| const location = typeof window !== 'undefined' ? window.location : undefined; | ||
| try { | ||
| return new URL(url, location?.href).origin; | ||
| } catch { | ||
| return location?.origin ?? 'null'; | ||
| } | ||
| } | ||
| function sendToolState({ | ||
@@ -54,6 +70,9 @@ bridge, | ||
| hostContextRef.current = hostContext; | ||
| const targetOrigin = sandbox.targetOrigin ?? '*'; | ||
| const sandboxUrl = String(sandbox.url); | ||
| const targetOrigin = sandbox.targetOrigin ?? deriveTargetOrigin(sandboxUrl); | ||
| const resourceCSP = getMCPAppCSP(resource.meta?.csp); | ||
| const resourceAllow = getMCPAppAllowAttribute(resource.meta?.permissions); | ||
| const resourceAllow = getMCPAppAllowAttribute( | ||
| resource.meta?.permissions, | ||
| sandbox.allowedPermissions, | ||
| ); | ||
| const innerSandbox = sandbox.innerSandbox ?? MCP_APP_DEFAULT_INNER_SANDBOX; | ||
@@ -97,4 +116,8 @@ const bridgeHandlers = useMemo( | ||
| const onMessage = (event: MessageEvent) => { | ||
| // Only handle messages from the proxy window and expected origin. | ||
| if (!bridge.acceptsEvent(event)) { | ||
| return; | ||
| } | ||
| if ( | ||
| event.source === targetWindow && | ||
| event.data?.jsonrpc === '2.0' && | ||
@@ -164,2 +187,5 @@ event.data.method === 'ui/notifications/sandbox-proxy-ready' | ||
| style={sandbox.style} | ||
| // Permissions Policy is hierarchical: the outer frame must delegate a | ||
| // feature for the proxy to re-delegate it to the inner app frame. | ||
| allow={resourceAllow} | ||
| sandbox={sandbox.outerSandbox ?? MCP_APP_DEFAULT_OUTER_SANDBOX} | ||
@@ -166,0 +192,0 @@ /> |
@@ -67,2 +67,57 @@ import { isJSONObject } from '@ai-sdk/provider'; | ||
| /** | ||
| * Validates `resources/read` params and limits reads to `ui://` app resources. | ||
| */ | ||
| function assertResourceReadParams(params: unknown): { uri: string } { | ||
| if (!isJSONObject(params) || typeof params.uri !== 'string') { | ||
| throw new Error('Invalid resources/read params'); | ||
| } | ||
| if (!params.uri.startsWith('ui://')) { | ||
| throw new Error( | ||
| `resources/read is limited to ui:// resources: ${params.uri}`, | ||
| ); | ||
| } | ||
| return { uri: params.uri }; | ||
| } | ||
| /** | ||
| * Validates `ui/open-link` params and allows only `https:`/`http:`/`mailto:` | ||
| * URLs. | ||
| */ | ||
| function assertOpenLinkParams(params: unknown): { url: string } { | ||
| if (!isJSONObject(params) || typeof params.url !== 'string') { | ||
| throw new Error('Invalid ui/open-link params'); | ||
| } | ||
| let scheme: string; | ||
| try { | ||
| scheme = new URL(params.url).protocol; | ||
| } catch { | ||
| throw new Error(`Invalid ui/open-link url: ${params.url}`); | ||
| } | ||
| if (scheme !== 'https:' && scheme !== 'http:' && scheme !== 'mailto:') { | ||
| throw new Error(`Disallowed ui/open-link scheme: ${scheme}`); | ||
| } | ||
| return { url: params.url }; | ||
| } | ||
| /** | ||
| * Validates params for `ui/request-display-mode`. | ||
| */ | ||
| function assertDisplayModeParams(params: unknown): { | ||
| mode: 'inline' | 'fullscreen' | 'pip'; | ||
| } { | ||
| if ( | ||
| !isJSONObject(params) || | ||
| (params.mode !== 'inline' && | ||
| params.mode !== 'fullscreen' && | ||
| params.mode !== 'pip') | ||
| ) { | ||
| throw new Error('Invalid ui/request-display-mode params'); | ||
| } | ||
| return { mode: params.mode }; | ||
| } | ||
| /** | ||
| * Host-side JSON-RPC bridge for one MCP App iframe. | ||
@@ -150,6 +205,18 @@ * | ||
| /** | ||
| * Whether a `message` event came from the expected proxy window and origin. | ||
| * The origin check is skipped only when `targetOrigin` is the `'*'` default. | ||
| * Callers that intercept events before {@link handleMessage} share this check. | ||
| */ | ||
| acceptsEvent(event: MessageEvent): boolean { | ||
| return ( | ||
| event.source === this.targetWindow && | ||
| (this.targetOrigin === '*' || event.origin === this.targetOrigin) | ||
| ); | ||
| } | ||
| /** | ||
| * Processes one `message` event from the sandbox proxy iframe. | ||
| */ | ||
| handleMessage(event: MessageEvent): void { | ||
| if (event.source !== this.targetWindow || !isJsonRpcMessage(event.data)) { | ||
| if (!this.acceptsEvent(event) || !isJsonRpcMessage(event.data)) { | ||
| return; | ||
@@ -312,3 +379,5 @@ } | ||
| } | ||
| return this.handlers.readResource(request.params as { uri: string }); | ||
| return this.handlers.readResource( | ||
| assertResourceReadParams(request.params), | ||
| ); | ||
@@ -325,3 +394,3 @@ case 'resources/list': | ||
| } | ||
| return this.handlers.openLink(request.params as { url: string }); | ||
| return this.handlers.openLink(assertOpenLinkParams(request.params)); | ||
@@ -337,3 +406,3 @@ case 'ui/message': | ||
| this.handlers.requestDisplayMode?.( | ||
| request.params as { mode: 'inline' | 'fullscreen' | 'pip' }, | ||
| assertDisplayModeParams(request.params), | ||
| ) ?? { mode: this.hostContext.displayMode ?? 'inline' } | ||
@@ -340,0 +409,0 @@ ); |
| export { MCPAppRenderer as experimental_MCPAppRenderer } from './app-renderer'; | ||
| export type { MCPAppPermission } from './sandbox'; | ||
| export type { | ||
@@ -3,0 +4,0 @@ MCPAppBridgeHandlers, |
+30
-11
@@ -98,11 +98,31 @@ import type { MCPAppResourceCSP } from '@ai-sdk/mcp'; | ||
| /** | ||
| * A permission an MCP App may request and a host may allow. | ||
| */ | ||
| export type MCPAppPermission = | ||
| | 'camera' | ||
| | 'microphone' | ||
| | 'geolocation' | ||
| | 'clipboardWrite'; | ||
| const MCP_APP_PERMISSION_FEATURES: Record<MCPAppPermission, string> = { | ||
| camera: 'camera', | ||
| microphone: 'microphone', | ||
| geolocation: 'geolocation', | ||
| clipboardWrite: 'clipboard-write', | ||
| }; | ||
| /** | ||
| * Converts MCP App permission metadata into an iframe `allow` attribute. | ||
| * | ||
| * Deny-by-default: a capability is granted only when it is both requested in | ||
| * `permissions` and present in the host `allowedPermissions` allowlist. | ||
| * Omitting the allowlist grants nothing, mirroring `allowedTools` in the bridge. | ||
| * | ||
| * @example | ||
| * ```ts | ||
| * const allow = getMCPAppAllowAttribute({ | ||
| * microphone: {}, | ||
| * clipboardWrite: {}, | ||
| * }); | ||
| * // "microphone; clipboard-write" | ||
| * const allow = getMCPAppAllowAttribute( | ||
| * { microphone: {}, camera: {} }, | ||
| * ['microphone'], | ||
| * ); | ||
| * // "microphone" — camera requested by server but not host-allowed | ||
| * ``` | ||
@@ -112,14 +132,13 @@ */ | ||
| permissions?: Record<string, unknown>, | ||
| allowedPermissions?: MCPAppPermission[], | ||
| ): string | undefined { | ||
| if (permissions == null) { | ||
| if (permissions == null || allowedPermissions == null) { | ||
| return undefined; | ||
| } | ||
| const allow = []; | ||
| if (permissions.camera) allow.push('camera'); | ||
| if (permissions.microphone) allow.push('microphone'); | ||
| if (permissions.geolocation) allow.push('geolocation'); | ||
| if (permissions.clipboardWrite) allow.push('clipboard-write'); | ||
| const allow = allowedPermissions | ||
| .filter(permission => Boolean(permissions[permission])) | ||
| .map(permission => MCP_APP_PERMISSION_FEATURES[permission]); | ||
| return allow.length > 0 ? allow.join('; ') : undefined; | ||
| } |
| import type { MCPAppResource } from '@ai-sdk/mcp'; | ||
| import type { DynamicToolUIPart, ToolUIPart, UITools } from 'ai'; | ||
| import type { CSSProperties, ReactNode } from 'react'; | ||
| import type { MCPAppPermission } from './sandbox'; | ||
@@ -58,2 +59,8 @@ export type { MCPAppResource }; | ||
| innerSandbox?: string; | ||
| /** | ||
| * Iframe capabilities the host allows an app to use. Deny-by-default: a | ||
| * server-requested permission (camera/microphone/geolocation/clipboardWrite) | ||
| * is only granted when it also appears here. Omitting this grants nothing. | ||
| */ | ||
| allowedPermissions?: MCPAppPermission[]; | ||
| }; | ||
@@ -60,0 +67,0 @@ |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
295465
4.25%3661
5.08%+ Added
- Removed
Updated