@tanstack/ai 0.37.0 → 0.38.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.
@@ -18,6 +18,7 @@ import type {
18
18
  AfterToolCallInfo,
19
19
  BeforeToolCallDecision,
20
20
  } from '../middleware/types'
21
+ import type { McpResourceReadResult } from '../mcp/types'
21
22
  import type {
22
23
  ContextFromTool,
23
24
  DefinedContext,
@@ -33,6 +34,92 @@ function safeJsonParse(value: string): unknown {
33
34
  }
34
35
  }
35
36
 
37
+ /**
38
+ * MCP Apps metadata attached to a server tool at discovery (see
39
+ * `@tanstack/ai-mcp` discovery + `MCPManager.discover()`).
40
+ *
41
+ * - `uiResourceUri` / `serverId` are stamped by ai-mcp at tool discovery.
42
+ * - `readResource` is bound by `MCPManager.discover()` (the one site that has
43
+ * both the tool and its originating source) so the resource can be eagerly
44
+ * read at the emit site. Under `chat()`-managed MCP lifecycle
45
+ * (`connection:'close'`), the MCP source is not disposed until the run
46
+ * drains, so `readResource` is still live at this emit point. Note: a caller
47
+ * who closes the MCP source early (outside `chat()`'s managed lifecycle)
48
+ * degrades fail-soft — `readResource` may reject, the widget is absent, but
49
+ * the tool result still flows to the model.
50
+ * `@tanstack/ai` never imports `@tanstack/ai-mcp`; this travels structurally
51
+ * on the tool.
52
+ */
53
+ interface McpToolAppMeta {
54
+ uiResourceUri?: string
55
+ serverId?: string
56
+ /** Server-native (unprefixed) MCP tool name — used as the renderer's toolName. */
57
+ serverToolName?: string
58
+ readResource?: (uri: string) => Promise<McpResourceReadResult>
59
+ }
60
+
61
+ function readMcpAppMeta(tool: AnyTool): McpToolAppMeta | undefined {
62
+ const meta = (tool.metadata as { mcp?: McpToolAppMeta } | undefined)?.mcp
63
+ return meta
64
+ }
65
+
66
+ /**
67
+ * Eagerly read a tool's linked `ui://` resource (MCP Apps) and emit a
68
+ * `ui-resource` CUSTOM event so the client can render the widget. The model
69
+ * still receives the normal text tool-result; the widget rides alongside and
70
+ * never enters model input.
71
+ *
72
+ * Fail-soft: any read error logs a warning and emits nothing — it never throws,
73
+ * so the normal tool-result still flows and a broken widget cannot break the run.
74
+ */
75
+ async function emitUiResourceIfLinked<TContext>(
76
+ tool: AnyTool,
77
+ context: ToolExecutionContext<TContext>,
78
+ ): Promise<void> {
79
+ const mcp = readMcpAppMeta(tool)
80
+ const uiUri = mcp?.uiResourceUri
81
+ if (!uiUri || !mcp.readResource) return
82
+
83
+ // The try covers ONLY the fallible read — keep `emitCustomEvent` out of it so
84
+ // an exception from the emit path can't be mislabeled as a read failure.
85
+ let matched: McpResourceReadResult['contents'][number] | undefined
86
+ try {
87
+ const res = await mcp.readResource(uiUri)
88
+ // Emit ONLY the content whose uri matches the requested `uiUri`. A source
89
+ // can return unrelated contents; falling back to `contents[0]` would risk
90
+ // rendering a widget that doesn't correspond to the linked resource. This
91
+ // is a display widget — a mismatched resource is worse than none, so if no
92
+ // content matches we fail-soft (warn + return) rather than emit.
93
+ matched = res.contents.find((c) => c.uri === uiUri)
94
+ } catch (err) {
95
+ // fail-soft — the text tool-result already flows; a broken widget must
96
+ // not break the run.
97
+ console.warn(`[mcp-apps] failed to read ui resource ${uiUri}:`, err)
98
+ return
99
+ }
100
+ if (!matched) {
101
+ console.warn(
102
+ `[mcp-apps] ui resource ${uiUri} returned no content matching that uri; not emitting`,
103
+ )
104
+ return
105
+ }
106
+ // NOTE: `toolCallId` is intentionally NOT set here — it is stamped onto
107
+ // every emitted event by the `executeToolCalls` context wrapper, so the
108
+ // UIResourceEvent.value.toolCallId / UIResourcePart.toolCallId contract is
109
+ // still satisfied downstream.
110
+ context.emitCustomEvent('ui-resource', {
111
+ resource: {
112
+ uri: matched.uri,
113
+ mimeType: matched.mimeType ?? 'text/html',
114
+ text: matched.text,
115
+ blob: matched.blob,
116
+ },
117
+ serverId: mcp.serverId,
118
+ toolName: mcp.serverToolName ?? tool.name,
119
+ meta: undefined,
120
+ })
121
+ }
122
+
36
123
  /**
37
124
  * Optional middleware hooks for tool execution.
38
125
  * When provided, these callbacks are invoked before/after each tool execution.
@@ -456,7 +543,7 @@ async function applyBeforeToolCallDecision(
456
543
  * Execute a server-side tool with event polling, output validation, and middleware hooks.
457
544
  * Yields CustomEvent chunks during execution and pushes the result to the results array.
458
545
  */
459
- async function* executeServerTool<TContext = unknown>(
546
+ export async function* executeServerTool<TContext = unknown>(
460
547
  toolCall: ToolCall,
461
548
  tool: AnyTool,
462
549
  toolName: string,
@@ -475,7 +562,14 @@ async function* executeServerTool<TContext = unknown>(
475
562
  let result = yield* executeWithEventPolling(executionPromise, pendingEvents)
476
563
  const duration = Date.now() - startTime
477
564
 
478
- // Flush remaining events
565
+ // MCP Apps: if this tool links a ui:// resource, eagerly read it and queue
566
+ // a `ui-resource` CUSTOM event. The MCP source stays live until the run
567
+ // drains (MCPManager's `connection:'close'` policy disposes on completion),
568
+ // so `readResource` is callable here. Fail-soft: a read error warns and
569
+ // emits nothing — the text result still flows.
570
+ await emitUiResourceIfLinked(tool, context)
571
+
572
+ // Flush remaining events (including any queued ui-resource event)
479
573
  let pendingEvent: CustomEvent | undefined
480
574
  while ((pendingEvent = pendingEvents.shift()) !== undefined) {
481
575
  yield pendingEvent
package/src/client.ts CHANGED
@@ -114,6 +114,7 @@ export type {
114
114
  ToolCallPart,
115
115
  ToolResultPart,
116
116
  UIMessage,
117
+ UIResourcePart,
117
118
  VideoPart,
118
119
  InferSchemaType,
119
120
  } from './types'
package/src/types.ts CHANGED
@@ -416,6 +416,23 @@ export interface StructuredOutputPart<TData = unknown> {
416
416
  errorMessage?: string
417
417
  }
418
418
 
419
+ export interface UIResourcePart {
420
+ type: 'ui-resource'
421
+ /** The ui:// resource object in MCP-native shape — fed straight to the renderer. */
422
+ resource: { uri: string; mimeType: string; text?: string; blob?: string }
423
+ /** Pool prefix / config key — routes interactive calls to the right MCP server. */
424
+ serverId?: string
425
+ /** Links the widget to the originating tool call — correlates it with the
426
+ * sibling ToolCallPart/ToolResultPart in the same message. */
427
+ toolCallId: string
428
+ /** Server-native (unprefixed) MCP tool name whose UI this resource renders.
429
+ * Required by the renderer (`@mcp-ui/client`'s `AppRenderer` `toolName` prop). */
430
+ toolName: string
431
+ /** Reserved for future passthrough of the resource/tool `_meta.ui` (e.g. frame-size hints).
432
+ * Currently always `undefined` — nothing populates this field yet. */
433
+ meta?: Record<string, unknown>
434
+ }
435
+
419
436
  export type MessagePart<TData = unknown> =
420
437
  | TextPart
421
438
  | ImagePart
@@ -426,6 +443,7 @@ export type MessagePart<TData = unknown> =
426
443
  | ToolResultPart
427
444
  | ThinkingPart
428
445
  | StructuredOutputPart<TData>
446
+ | UIResourcePart
429
447
 
430
448
  /**
431
449
  * UIMessage - Domain-specific message format optimized for building chat UIs
@@ -1308,6 +1326,19 @@ export interface ToolInputAvailableEvent extends CustomEvent {
1308
1326
  }
1309
1327
  }
1310
1328
 
1329
+ /** Emitted when an MCP tool returns a ui:// resource (MCP Apps). Reconciled into
1330
+ * a UIResourcePart on the assistant UIMessage. Never enters model input. */
1331
+ export interface UIResourceEvent extends CustomEvent {
1332
+ name: 'ui-resource'
1333
+ value: {
1334
+ resource: UIResourcePart['resource']
1335
+ serverId?: string
1336
+ toolCallId: string
1337
+ toolName: string
1338
+ meta?: Record<string, unknown>
1339
+ }
1340
+ }
1341
+
1311
1342
  /**
1312
1343
  * Public type for streams returned by `chat({ outputSchema, stream: true })`.
1313
1344
  *