@tanstack/ai-preact 0.9.15 → 0.10.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.
@@ -1,3 +1,5 @@
1
1
  export { useChat } from './use-chat.js';
2
+ export { useMcpAppBridge } from './use-mcp-app-bridge.js';
3
+ export type { UseMcpAppBridgeOptions } from './use-mcp-app-bridge.js';
2
4
  export type { UseChatOptions, UseChatReturn, UIMessage, ChatRequestBody, } from './types.js';
3
- export { fetchServerSentEvents, fetchHttpStream, xhrServerSentEvents, xhrHttpStream, stream, rpcStream, createChatClientOptions, type ConnectionAdapter, type ConnectConnectionAdapter, type SubscribeConnectionAdapter, type RunAgentInputContext, type FetchConnectionOptions, type XhrConnectionOptions, type InferChatMessages, } from '@tanstack/ai-client';
5
+ export { fetchServerSentEvents, fetchHttpStream, xhrServerSentEvents, xhrHttpStream, stream, rpcStream, createChatClientOptions, createMcpAppBridge, type McpAppBridge, type CreateMcpAppBridgeOptions, type ConnectionAdapter, type ConnectConnectionAdapter, type SubscribeConnectionAdapter, type RunAgentInputContext, type FetchConnectionOptions, type XhrConnectionOptions, type InferChatMessages, } from '@tanstack/ai-client';
package/dist/esm/index.js CHANGED
@@ -1,12 +1,15 @@
1
1
  import { useChat } from "./use-chat.js";
2
- import { createChatClientOptions, fetchHttpStream, fetchServerSentEvents, rpcStream, stream, xhrHttpStream, xhrServerSentEvents } from "@tanstack/ai-client";
2
+ import { useMcpAppBridge } from "./use-mcp-app-bridge.js";
3
+ import { createChatClientOptions, createMcpAppBridge, fetchHttpStream, fetchServerSentEvents, rpcStream, stream, xhrHttpStream, xhrServerSentEvents } from "@tanstack/ai-client";
3
4
  export {
4
5
  createChatClientOptions,
6
+ createMcpAppBridge,
5
7
  fetchHttpStream,
6
8
  fetchServerSentEvents,
7
9
  rpcStream,
8
10
  stream,
9
11
  useChat,
12
+ useMcpAppBridge,
10
13
  xhrHttpStream,
11
14
  xhrServerSentEvents
12
15
  };
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;"}
1
+ {"version":3,"file":"index.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;"}
@@ -0,0 +1,25 @@
1
+ import { UIResourcePart } from '@tanstack/ai';
2
+ import { McpAppBridge } from '@tanstack/ai-client';
3
+ export interface MCPAppResourceProps {
4
+ /** The ui-resource part from a UIMessage assistant part. */
5
+ part: UIResourcePart;
6
+ /**
7
+ * Framework-agnostic bridge for tool calls, prompt sending, and link opening.
8
+ * Omit it to render the widget in display-only mode — iframe interactions
9
+ * that would trigger tool calls or prompts are ignored.
10
+ */
11
+ bridge?: McpAppBridge;
12
+ /** Sandbox iframe configuration — must include the proxy page URL. */
13
+ sandbox: {
14
+ url: URL;
15
+ };
16
+ /** Optional structured arguments forwarded to the guest UI once it's ready. */
17
+ toolInput?: Record<string, unknown>;
18
+ }
19
+ /**
20
+ * Renders an MCP App UI resource inside a sandboxed iframe.
21
+ *
22
+ * Wraps `@mcp-ui/client`'s `AppRenderer` and wires its callbacks to a
23
+ * framework-agnostic {@link McpAppBridge}.
24
+ */
25
+ export declare function MCPAppResource(props: MCPAppResourceProps): import("preact").JSX.Element;
@@ -0,0 +1,52 @@
1
+ import { jsx } from "preact/jsx-runtime";
2
+ import { AppRenderer } from "@mcp-ui/client";
3
+ function isRecord(value) {
4
+ return typeof value === "object" && value !== null && !Array.isArray(value);
5
+ }
6
+ function resultToText(result) {
7
+ if (typeof result === "string") return result;
8
+ const stringify = JSON.stringify;
9
+ return stringify(result) ?? "null";
10
+ }
11
+ function MCPAppResource(props) {
12
+ const { part, bridge, sandbox, toolInput } = props;
13
+ const onCallTool = bridge ? async (params) => {
14
+ const result = await bridge.callTool({
15
+ serverId: part.serverId,
16
+ toolName: params.name,
17
+ args: params.arguments
18
+ });
19
+ return {
20
+ content: [
21
+ {
22
+ type: "text",
23
+ text: resultToText(result)
24
+ }
25
+ ],
26
+ structuredContent: isRecord(result) ? result : void 0
27
+ };
28
+ } : void 0;
29
+ const onMessage = bridge ? async (params) => {
30
+ const text = params.content.filter((c) => c.type === "text").map((c) => c.text).join("");
31
+ if (text) await bridge.sendPrompt(text);
32
+ return {};
33
+ } : void 0;
34
+ const onOpenLink = bridge ? (params) => Promise.resolve(bridge.openLink(params.url)) : void 0;
35
+ return /* @__PURE__ */ jsx(
36
+ AppRenderer,
37
+ {
38
+ toolName: part.toolName,
39
+ sandbox,
40
+ html: part.resource.text,
41
+ toolResourceUri: part.resource.uri,
42
+ toolInput,
43
+ onCallTool,
44
+ onMessage,
45
+ onOpenLink
46
+ }
47
+ );
48
+ }
49
+ export {
50
+ MCPAppResource
51
+ };
52
+ //# sourceMappingURL=mcp-apps.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mcp-apps.js","sources":["../../src/mcp-apps.tsx"],"sourcesContent":["/**\n * MCPAppResource — Preact wrapper for rendering MCP App UI resources.\n *\n * PREACT/COMPAT NOTE:\n * `@mcp-ui/client@7.1.1` is published React-only: `AppRenderer` is a React\n * `forwardRef` component. There is NO dedicated preact entry point and NO web\n * component variant. This wrapper imports `{ AppRenderer }` from\n * `'@mcp-ui/client'` identically to the React wrapper and relies on the\n * *consumer's* `preact/compat` alias (resolving `react` / `react-dom` to\n * `preact/compat`) to render the React component under Preact.\n * Wiring up that alias is the consumer's responsibility and is NOT\n * runtime-verified in this repository.\n */\nimport { AppRenderer } from '@mcp-ui/client'\nimport type { AppRendererProps } from '@mcp-ui/client'\nimport type { UIResourcePart } from '@tanstack/ai'\nimport type { McpAppBridge } from '@tanstack/ai-client'\n\nexport interface MCPAppResourceProps {\n /** The ui-resource part from a UIMessage assistant part. */\n part: UIResourcePart\n /**\n * Framework-agnostic bridge for tool calls, prompt sending, and link opening.\n * Omit it to render the widget in display-only mode — iframe interactions\n * that would trigger tool calls or prompts are ignored.\n */\n bridge?: McpAppBridge\n /** Sandbox iframe configuration — must include the proxy page URL. */\n sandbox: { url: URL }\n /** Optional structured arguments forwarded to the guest UI once it's ready. */\n toolInput?: Record<string, unknown>\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value)\n}\n\n/**\n * Coalesce an arbitrary bridge result into the `string` required by the\n * `CallToolResult` text content block. `JSON.stringify` is typed to return\n * `string` but actually returns `undefined` for inputs like `undefined` or a\n * function, so we narrow explicitly to avoid `text: undefined`.\n */\nfunction resultToText(result: unknown): string {\n if (typeof result === 'string') return result\n // `JSON.stringify`'s lib signature claims `string`, but it returns\n // `undefined` for `undefined`/function inputs; type the call honestly.\n const stringify: (value: unknown) => string | undefined = JSON.stringify\n return stringify(result) ?? 'null'\n}\n\n/**\n * Renders an MCP App UI resource inside a sandboxed iframe.\n *\n * Wraps `@mcp-ui/client`'s `AppRenderer` and wires its callbacks to a\n * framework-agnostic {@link McpAppBridge}.\n */\nexport function MCPAppResource(props: MCPAppResourceProps) {\n const { part, bridge, sandbox, toolInput } = props\n\n const onCallTool: AppRendererProps['onCallTool'] = bridge\n ? async (params) => {\n const result = await bridge.callTool({\n serverId: part.serverId,\n toolName: params.name,\n args: params.arguments,\n })\n\n return {\n content: [\n {\n type: 'text' as const,\n text: resultToText(result),\n },\n ],\n structuredContent: isRecord(result) ? result : undefined,\n }\n }\n : undefined\n\n const onMessage: AppRendererProps['onMessage'] = bridge\n ? async (params) => {\n const text = params.content\n .filter((c): c is { type: 'text'; text: string } => c.type === 'text')\n .map((c) => c.text)\n .join('')\n if (text) await bridge.sendPrompt(text)\n return {}\n }\n : undefined\n\n const onOpenLink: AppRendererProps['onOpenLink'] = bridge\n ? (params) => Promise.resolve(bridge.openLink(params.url))\n : undefined\n\n return (\n <AppRenderer\n toolName={part.toolName}\n sandbox={sandbox}\n html={part.resource.text}\n toolResourceUri={part.resource.uri}\n toolInput={toolInput}\n onCallTool={onCallTool}\n onMessage={onMessage}\n onOpenLink={onOpenLink}\n />\n )\n}\n"],"names":[],"mappings":";;AAiCA,SAAS,SAAS,OAAkD;AAClE,SAAO,OAAO,UAAU,YAAY,UAAU,QAAQ,CAAC,MAAM,QAAQ,KAAK;AAC5E;AAQA,SAAS,aAAa,QAAyB;AAC7C,MAAI,OAAO,WAAW,SAAU,QAAO;AAGvC,QAAM,YAAoD,KAAK;AAC/D,SAAO,UAAU,MAAM,KAAK;AAC9B;AAQO,SAAS,eAAe,OAA4B;AACzD,QAAM,EAAE,MAAM,QAAQ,SAAS,cAAc;AAE7C,QAAM,aAA6C,SAC/C,OAAO,WAAW;AAChB,UAAM,SAAS,MAAM,OAAO,SAAS;AAAA,MACnC,UAAU,KAAK;AAAA,MACf,UAAU,OAAO;AAAA,MACjB,MAAM,OAAO;AAAA,IAAA,CACd;AAED,WAAO;AAAA,MACL,SAAS;AAAA,QACP;AAAA,UACE,MAAM;AAAA,UACN,MAAM,aAAa,MAAM;AAAA,QAAA;AAAA,MAC3B;AAAA,MAEF,mBAAmB,SAAS,MAAM,IAAI,SAAS;AAAA,IAAA;AAAA,EAEnD,IACA;AAEJ,QAAM,YAA2C,SAC7C,OAAO,WAAW;AAChB,UAAM,OAAO,OAAO,QACjB,OAAO,CAAC,MAA2C,EAAE,SAAS,MAAM,EACpE,IAAI,CAAC,MAAM,EAAE,IAAI,EACjB,KAAK,EAAE;AACV,QAAI,KAAM,OAAM,OAAO,WAAW,IAAI;AACtC,WAAO,CAAA;AAAA,EACT,IACA;AAEJ,QAAM,aAA6C,SAC/C,CAAC,WAAW,QAAQ,QAAQ,OAAO,SAAS,OAAO,GAAG,CAAC,IACvD;AAEJ,SACE;AAAA,IAAC;AAAA,IAAA;AAAA,MACC,UAAU,KAAK;AAAA,MACf;AAAA,MACA,MAAM,KAAK,SAAS;AAAA,MACpB,iBAAiB,KAAK,SAAS;AAAA,MAC/B;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,IAAA;AAAA,EAAA;AAGN;"}
@@ -0,0 +1,25 @@
1
+ import { CreateMcpAppBridgeOptions, McpAppBridge } from '@tanstack/ai-client';
2
+ export type UseMcpAppBridgeOptions = CreateMcpAppBridgeOptions;
3
+ /**
4
+ * Preact wrapper around `createMcpAppBridge` that returns a **stable** bridge for
5
+ * the given `threadId`/`callEndpoint`, while always invoking the latest
6
+ * `chat.sendMessage` and `onLink` (kept in refs). This avoids both recreating
7
+ * the bridge on every render and the stale-closure / `exhaustive-deps` dance
8
+ * you'd otherwise write by hand:
9
+ *
10
+ * ```tsx
11
+ * const { sendMessage } = useChat({ threadId, connection })
12
+ * const bridge = useMcpAppBridge({
13
+ * threadId,
14
+ * callEndpoint: '/api/mcp-apps-call',
15
+ * chat: { sendMessage: async (content) => void sendMessage(content) },
16
+ * onLink: (url) => window.open(url, '_blank', 'noopener,noreferrer'),
17
+ * })
18
+ * // pass `bridge` to <MCPAppResource bridge={bridge} … />
19
+ * ```
20
+ *
21
+ * The bridge is recreated only when `threadId`, `callEndpoint`, `fetchImpl`, or
22
+ * the *presence* of `onLink` changes — passing a new inline `onLink`/`sendMessage`
23
+ * each render does not churn it.
24
+ */
25
+ export declare function useMcpAppBridge(options: UseMcpAppBridgeOptions): McpAppBridge;
@@ -0,0 +1,26 @@
1
+ import { useRef, useMemo } from "preact/hooks";
2
+ import { createMcpAppBridge } from "@tanstack/ai-client";
3
+ function useMcpAppBridge(options) {
4
+ const { threadId, callEndpoint, chat, fetchImpl, onLink } = options;
5
+ const chatRef = useRef(chat);
6
+ chatRef.current = chat;
7
+ const onLinkRef = useRef(onLink);
8
+ onLinkRef.current = onLink;
9
+ const hasOnLink = onLink != null;
10
+ return useMemo(
11
+ () => createMcpAppBridge({
12
+ threadId,
13
+ callEndpoint,
14
+ fetchImpl,
15
+ chat: {
16
+ sendMessage: (content, body) => chatRef.current.sendMessage(content, body)
17
+ },
18
+ onLink: hasOnLink ? (url) => onLinkRef.current?.(url) : void 0
19
+ }),
20
+ [threadId, callEndpoint, fetchImpl, hasOnLink]
21
+ );
22
+ }
23
+ export {
24
+ useMcpAppBridge
25
+ };
26
+ //# sourceMappingURL=use-mcp-app-bridge.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-mcp-app-bridge.js","sources":["../../src/use-mcp-app-bridge.ts"],"sourcesContent":["import { useMemo, useRef } from 'preact/hooks'\nimport { createMcpAppBridge } from '@tanstack/ai-client'\nimport type {\n CreateMcpAppBridgeOptions,\n McpAppBridge,\n} from '@tanstack/ai-client'\n\nexport type UseMcpAppBridgeOptions = CreateMcpAppBridgeOptions\n\n/**\n * Preact wrapper around `createMcpAppBridge` that returns a **stable** bridge for\n * the given `threadId`/`callEndpoint`, while always invoking the latest\n * `chat.sendMessage` and `onLink` (kept in refs). This avoids both recreating\n * the bridge on every render and the stale-closure / `exhaustive-deps` dance\n * you'd otherwise write by hand:\n *\n * ```tsx\n * const { sendMessage } = useChat({ threadId, connection })\n * const bridge = useMcpAppBridge({\n * threadId,\n * callEndpoint: '/api/mcp-apps-call',\n * chat: { sendMessage: async (content) => void sendMessage(content) },\n * onLink: (url) => window.open(url, '_blank', 'noopener,noreferrer'),\n * })\n * // pass `bridge` to <MCPAppResource bridge={bridge} … />\n * ```\n *\n * The bridge is recreated only when `threadId`, `callEndpoint`, `fetchImpl`, or\n * the *presence* of `onLink` changes — passing a new inline `onLink`/`sendMessage`\n * each render does not churn it.\n */\nexport function useMcpAppBridge(options: UseMcpAppBridgeOptions): McpAppBridge {\n const { threadId, callEndpoint, chat, fetchImpl, onLink } = options\n\n // Latest-value refs so the bridge identity stays stable but its callbacks are\n // never stale (the bridge calls `.current` at invocation time, not creation).\n const chatRef = useRef(chat)\n chatRef.current = chat\n const onLinkRef = useRef(onLink)\n onLinkRef.current = onLink\n\n // Whether a link handler was supplied governs the bridge's link behavior\n // (forward vs. display-only warn), so it's part of the bridge's identity.\n const hasOnLink = onLink != null\n\n return useMemo(\n () =>\n createMcpAppBridge({\n threadId,\n callEndpoint,\n fetchImpl,\n chat: {\n sendMessage: (content, body) =>\n chatRef.current.sendMessage(content, body),\n },\n onLink: hasOnLink ? (url) => onLinkRef.current?.(url) : undefined,\n }),\n [threadId, callEndpoint, fetchImpl, hasOnLink],\n )\n}\n"],"names":[],"mappings":";;AA+BO,SAAS,gBAAgB,SAA+C;AAC7E,QAAM,EAAE,UAAU,cAAc,MAAM,WAAW,WAAW;AAI5D,QAAM,UAAU,OAAO,IAAI;AAC3B,UAAQ,UAAU;AAClB,QAAM,YAAY,OAAO,MAAM;AAC/B,YAAU,UAAU;AAIpB,QAAM,YAAY,UAAU;AAE5B,SAAO;AAAA,IACL,MACE,mBAAmB;AAAA,MACjB;AAAA,MACA;AAAA,MACA;AAAA,MACA,MAAM;AAAA,QACJ,aAAa,CAAC,SAAS,SACrB,QAAQ,QAAQ,YAAY,SAAS,IAAI;AAAA,MAAA;AAAA,MAE7C,QAAQ,YAAY,CAAC,QAAQ,UAAU,UAAU,GAAG,IAAI;AAAA,IAAA,CACzD;AAAA,IACH,CAAC,UAAU,cAAc,WAAW,SAAS;AAAA,EAAA;AAEjD;"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanstack/ai-preact",
3
- "version": "0.9.15",
3
+ "version": "0.10.0",
4
4
  "description": "Preact hooks for TanStack AI streaming chat and typed messages.",
5
5
  "author": "Tanner Linsley",
6
6
  "license": "MIT",
@@ -24,6 +24,10 @@
24
24
  ".": {
25
25
  "types": "./dist/esm/index.d.ts",
26
26
  "import": "./dist/esm/index.js"
27
+ },
28
+ "./mcp-apps": {
29
+ "types": "./dist/esm/mcp-apps.d.ts",
30
+ "import": "./dist/esm/mcp-apps.js"
27
31
  }
28
32
  },
29
33
  "files": [
@@ -42,18 +46,26 @@
42
46
  "tool-calling"
43
47
  ],
44
48
  "dependencies": {
45
- "@tanstack/ai-client": "0.18.6"
49
+ "@tanstack/ai-client": "0.19.0"
46
50
  },
47
51
  "devDependencies": {
52
+ "@mcp-ui/client": "^7.1.1",
48
53
  "@testing-library/preact": "^3.2.4",
49
54
  "@vitest/coverage-v8": "4.0.14",
50
55
  "jsdom": "^27.2.0",
51
56
  "preact": "^10.26.9",
52
- "vite": "^7.3.3"
57
+ "vite": "^7.3.3",
58
+ "@tanstack/ai": "0.38.0"
53
59
  },
54
60
  "peerDependencies": {
61
+ "@mcp-ui/client": "^7",
55
62
  "preact": ">=10.11.0",
56
- "@tanstack/ai": "^0.37.0"
63
+ "@tanstack/ai": "^0.38.0"
64
+ },
65
+ "peerDependenciesMeta": {
66
+ "@mcp-ui/client": {
67
+ "optional": true
68
+ }
57
69
  },
58
70
  "scripts": {
59
71
  "clean": "premove ./build ./dist",
package/src/index.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  export { useChat } from './use-chat'
2
+ export { useMcpAppBridge } from './use-mcp-app-bridge'
3
+ export type { UseMcpAppBridgeOptions } from './use-mcp-app-bridge'
2
4
  export type {
3
5
  UseChatOptions,
4
6
  UseChatReturn,
@@ -14,6 +16,9 @@ export {
14
16
  stream,
15
17
  rpcStream,
16
18
  createChatClientOptions,
19
+ createMcpAppBridge,
20
+ type McpAppBridge,
21
+ type CreateMcpAppBridgeOptions,
17
22
  type ConnectionAdapter,
18
23
  type ConnectConnectionAdapter,
19
24
  type SubscribeConnectionAdapter,
@@ -0,0 +1,108 @@
1
+ /**
2
+ * MCPAppResource — Preact wrapper for rendering MCP App UI resources.
3
+ *
4
+ * PREACT/COMPAT NOTE:
5
+ * `@mcp-ui/client@7.1.1` is published React-only: `AppRenderer` is a React
6
+ * `forwardRef` component. There is NO dedicated preact entry point and NO web
7
+ * component variant. This wrapper imports `{ AppRenderer }` from
8
+ * `'@mcp-ui/client'` identically to the React wrapper and relies on the
9
+ * *consumer's* `preact/compat` alias (resolving `react` / `react-dom` to
10
+ * `preact/compat`) to render the React component under Preact.
11
+ * Wiring up that alias is the consumer's responsibility and is NOT
12
+ * runtime-verified in this repository.
13
+ */
14
+ import { AppRenderer } from '@mcp-ui/client'
15
+ import type { AppRendererProps } from '@mcp-ui/client'
16
+ import type { UIResourcePart } from '@tanstack/ai'
17
+ import type { McpAppBridge } from '@tanstack/ai-client'
18
+
19
+ export interface MCPAppResourceProps {
20
+ /** The ui-resource part from a UIMessage assistant part. */
21
+ part: UIResourcePart
22
+ /**
23
+ * Framework-agnostic bridge for tool calls, prompt sending, and link opening.
24
+ * Omit it to render the widget in display-only mode — iframe interactions
25
+ * that would trigger tool calls or prompts are ignored.
26
+ */
27
+ bridge?: McpAppBridge
28
+ /** Sandbox iframe configuration — must include the proxy page URL. */
29
+ sandbox: { url: URL }
30
+ /** Optional structured arguments forwarded to the guest UI once it's ready. */
31
+ toolInput?: Record<string, unknown>
32
+ }
33
+
34
+ function isRecord(value: unknown): value is Record<string, unknown> {
35
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
36
+ }
37
+
38
+ /**
39
+ * Coalesce an arbitrary bridge result into the `string` required by the
40
+ * `CallToolResult` text content block. `JSON.stringify` is typed to return
41
+ * `string` but actually returns `undefined` for inputs like `undefined` or a
42
+ * function, so we narrow explicitly to avoid `text: undefined`.
43
+ */
44
+ function resultToText(result: unknown): string {
45
+ if (typeof result === 'string') return result
46
+ // `JSON.stringify`'s lib signature claims `string`, but it returns
47
+ // `undefined` for `undefined`/function inputs; type the call honestly.
48
+ const stringify: (value: unknown) => string | undefined = JSON.stringify
49
+ return stringify(result) ?? 'null'
50
+ }
51
+
52
+ /**
53
+ * Renders an MCP App UI resource inside a sandboxed iframe.
54
+ *
55
+ * Wraps `@mcp-ui/client`'s `AppRenderer` and wires its callbacks to a
56
+ * framework-agnostic {@link McpAppBridge}.
57
+ */
58
+ export function MCPAppResource(props: MCPAppResourceProps) {
59
+ const { part, bridge, sandbox, toolInput } = props
60
+
61
+ const onCallTool: AppRendererProps['onCallTool'] = bridge
62
+ ? async (params) => {
63
+ const result = await bridge.callTool({
64
+ serverId: part.serverId,
65
+ toolName: params.name,
66
+ args: params.arguments,
67
+ })
68
+
69
+ return {
70
+ content: [
71
+ {
72
+ type: 'text' as const,
73
+ text: resultToText(result),
74
+ },
75
+ ],
76
+ structuredContent: isRecord(result) ? result : undefined,
77
+ }
78
+ }
79
+ : undefined
80
+
81
+ const onMessage: AppRendererProps['onMessage'] = bridge
82
+ ? async (params) => {
83
+ const text = params.content
84
+ .filter((c): c is { type: 'text'; text: string } => c.type === 'text')
85
+ .map((c) => c.text)
86
+ .join('')
87
+ if (text) await bridge.sendPrompt(text)
88
+ return {}
89
+ }
90
+ : undefined
91
+
92
+ const onOpenLink: AppRendererProps['onOpenLink'] = bridge
93
+ ? (params) => Promise.resolve(bridge.openLink(params.url))
94
+ : undefined
95
+
96
+ return (
97
+ <AppRenderer
98
+ toolName={part.toolName}
99
+ sandbox={sandbox}
100
+ html={part.resource.text}
101
+ toolResourceUri={part.resource.uri}
102
+ toolInput={toolInput}
103
+ onCallTool={onCallTool}
104
+ onMessage={onMessage}
105
+ onOpenLink={onOpenLink}
106
+ />
107
+ )
108
+ }
@@ -0,0 +1,60 @@
1
+ import { useMemo, useRef } from 'preact/hooks'
2
+ import { createMcpAppBridge } from '@tanstack/ai-client'
3
+ import type {
4
+ CreateMcpAppBridgeOptions,
5
+ McpAppBridge,
6
+ } from '@tanstack/ai-client'
7
+
8
+ export type UseMcpAppBridgeOptions = CreateMcpAppBridgeOptions
9
+
10
+ /**
11
+ * Preact wrapper around `createMcpAppBridge` that returns a **stable** bridge for
12
+ * the given `threadId`/`callEndpoint`, while always invoking the latest
13
+ * `chat.sendMessage` and `onLink` (kept in refs). This avoids both recreating
14
+ * the bridge on every render and the stale-closure / `exhaustive-deps` dance
15
+ * you'd otherwise write by hand:
16
+ *
17
+ * ```tsx
18
+ * const { sendMessage } = useChat({ threadId, connection })
19
+ * const bridge = useMcpAppBridge({
20
+ * threadId,
21
+ * callEndpoint: '/api/mcp-apps-call',
22
+ * chat: { sendMessage: async (content) => void sendMessage(content) },
23
+ * onLink: (url) => window.open(url, '_blank', 'noopener,noreferrer'),
24
+ * })
25
+ * // pass `bridge` to <MCPAppResource bridge={bridge} … />
26
+ * ```
27
+ *
28
+ * The bridge is recreated only when `threadId`, `callEndpoint`, `fetchImpl`, or
29
+ * the *presence* of `onLink` changes — passing a new inline `onLink`/`sendMessage`
30
+ * each render does not churn it.
31
+ */
32
+ export function useMcpAppBridge(options: UseMcpAppBridgeOptions): McpAppBridge {
33
+ const { threadId, callEndpoint, chat, fetchImpl, onLink } = options
34
+
35
+ // Latest-value refs so the bridge identity stays stable but its callbacks are
36
+ // never stale (the bridge calls `.current` at invocation time, not creation).
37
+ const chatRef = useRef(chat)
38
+ chatRef.current = chat
39
+ const onLinkRef = useRef(onLink)
40
+ onLinkRef.current = onLink
41
+
42
+ // Whether a link handler was supplied governs the bridge's link behavior
43
+ // (forward vs. display-only warn), so it's part of the bridge's identity.
44
+ const hasOnLink = onLink != null
45
+
46
+ return useMemo(
47
+ () =>
48
+ createMcpAppBridge({
49
+ threadId,
50
+ callEndpoint,
51
+ fetchImpl,
52
+ chat: {
53
+ sendMessage: (content, body) =>
54
+ chatRef.current.sendMessage(content, body),
55
+ },
56
+ onLink: hasOnLink ? (url) => onLinkRef.current?.(url) : undefined,
57
+ }),
58
+ [threadId, callEndpoint, fetchImpl, hasOnLink],
59
+ )
60
+ }