@tanstack/ai-client 0.34.0 → 0.36.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,14 +1,15 @@
1
1
  import {
2
2
  EventType,
3
3
  StreamProcessor,
4
+ cloneAndDeepFreezeJson,
4
5
  convertSchemaToJsonSchema,
5
6
  generateMessageId,
6
7
  isStandardSchema,
7
8
  mergeMetadata,
8
9
  normalizeToUIMessage,
9
- parseWithStandardSchema,
10
10
  restoreInboundChunk,
11
11
  tanstackMetadata,
12
+ validateWithStandardSchema,
12
13
  } from '@tanstack/ai/client'
13
14
  import {
14
15
  ByokBlockedError,
@@ -88,6 +89,20 @@ interface InternalQueuedMessage extends QueuedMessage {
88
89
  body?: Record<string, any>
89
90
  }
90
91
 
92
+ const STREAM_PROCESSING_BUDGET_MS = 8
93
+
94
+ type SchedulerWithYield = {
95
+ yield?: () => Promise<void>
96
+ }
97
+
98
+ function yieldToHost(): Promise<void> {
99
+ const { scheduler } = globalThis as typeof globalThis & {
100
+ scheduler?: SchedulerWithYield
101
+ }
102
+ if (scheduler?.yield) return scheduler.yield()
103
+ return new Promise((resolve) => setTimeout(resolve, 0))
104
+ }
105
+
91
106
  function assertUniqueInterruptDefinitions(
92
107
  interrupts:
93
108
  | ReadonlyArray<InterruptDefinition<any, any, any, any>>
@@ -330,6 +345,7 @@ const REJOIN_CONNECT_DEADLINE_MS = 2000
330
345
  const REJOIN_REBUILD_TRIGGERS = new Set<string>([
331
346
  'TEXT_MESSAGE_START',
332
347
  'TEXT_MESSAGE_CONTENT',
348
+ 'REASONING_MESSAGE_CONTENT',
333
349
  'TOOL_CALL_START',
334
350
  'MESSAGES_SNAPSHOT',
335
351
  // Drop the hydrated card before this chunk creates it again. A subagent
@@ -337,6 +353,20 @@ const REJOIN_REBUILD_TRIGGERS = new Set<string>([
337
353
  'SUBAGENT_STARTED',
338
354
  ])
339
355
 
356
+ function rebuildsAssistantMessage(chunk: StreamChunk): boolean {
357
+ if (chunk.type === 'REASONING_ENCRYPTED_VALUE') {
358
+ return (
359
+ chunk.subtype === 'message' &&
360
+ typeof chunk.encryptedValue === 'string' &&
361
+ chunk.encryptedValue.length > 0
362
+ )
363
+ }
364
+ if (chunk.type === 'STEP_FINISHED') {
365
+ return 'signature' in chunk && Boolean(chunk.signature)
366
+ }
367
+ return REJOIN_REBUILD_TRIGGERS.has(chunk.type)
368
+ }
369
+
340
370
  type SubagentCard = Extract<UIMessage['parts'][number], { type: 'subagent' }>
341
371
 
342
372
  /** Every subagent card in the messages, nested cards included. */
@@ -506,6 +536,8 @@ export class ChatClient<
506
536
  private readonly activeRunIds = new Set<string>()
507
537
  /** Latched by `dispose()`; stops any late async callback starting new work. */
508
538
  private disposed = false
539
+ /** The error a failed mount hydration set, cleared by the next successful one. */
540
+ private hydrationError: Error | undefined
509
541
  /** Whether a view is currently watching. See `attach` / `detach`. */
510
542
  private tailing = false
511
543
  /** Constructor inputs `attach()` needs on every re-attach, not just the first. */
@@ -1124,7 +1156,10 @@ export class ChatClient<
1124
1156
  const generation = this.historyGeneration
1125
1157
  try {
1126
1158
  result = await hydrate(this.threadId, hydrateOptions)
1127
- } catch {
1159
+ } catch (cause) {
1160
+ // Same staleness guard as the success path below: a failure from an
1161
+ // older attempt must not touch the state of a newer one.
1162
+ if (generation === this.historyGeneration) this.failHydration(cause)
1128
1163
  return
1129
1164
  }
1130
1165
  if (generation !== this.historyGeneration) return
@@ -1140,13 +1175,25 @@ export class ChatClient<
1140
1175
  if (this.disposed || !this.tailing) return
1141
1176
  // A send may have started while the fetch was in flight — don't stomp it.
1142
1177
  if (this.isLoading || this.abortController) return
1178
+ // A retry after a failed load succeeded: drop that failure, but not an
1179
+ // error something else set since.
1180
+ if (this.hydrationError && this.error === this.hydrationError) {
1181
+ this.setError(undefined)
1182
+ this.setStatus('ready')
1183
+ }
1184
+ this.hydrationError = undefined
1143
1185
  this.applyHydrationPage(result.page)
1144
1186
  if (result.messages.length > 0) {
1145
1187
  const windowMessages = normalizeMessagesDates(result.messages)
1146
1188
  this.processor.setMessages(windowMessages)
1147
1189
  this.rememberServerMessageIds(windowMessages)
1148
1190
  }
1149
- if (result.interrupts && result.interrupts.pending.length > 0) {
1191
+ if (
1192
+ result.interrupts &&
1193
+ result.interrupts.pending.length > 0 &&
1194
+ (!result.activeRun?.runId ||
1195
+ result.activeRun.runId === result.interrupts.runId)
1196
+ ) {
1150
1197
  // Pending interrupt = the thread is paused awaiting a human decision, so
1151
1198
  // there is nothing to tail (no chunks stream until it resolves). Restore
1152
1199
  // the approval/wait from the SERVER — identical to reconstructing it from
@@ -1156,7 +1203,9 @@ export class ChatClient<
1156
1203
  // on the server, so a racing hydrate reports both an `activeRun` cursor
1157
1204
  // AND the pending interrupt. Tailing that "active" run would drop the
1158
1205
  // approval card (and hang on a stream that never comes), so the interrupt
1159
- // always wins.
1206
+ // wins over that same run. A DIFFERENT active run is the continuation
1207
+ // that answered this interrupt: the server commits the answer only when
1208
+ // that run finishes, so join it (see the `else` branch below).
1160
1209
  this.applyResumeSnapshot({
1161
1210
  resumeState: {
1162
1211
  threadId: this.threadId,
@@ -1170,6 +1219,36 @@ export class ChatClient<
1170
1219
  })()
1171
1220
  }
1172
1221
 
1222
+ /**
1223
+ * Surface a mount-hydration failure (`persistence: true`) on the observable
1224
+ * fields, mirroring `GenerationClient.failHydration`, so "this thread failed
1225
+ * to load" is distinguishable from "this thread has no messages" and the app
1226
+ * can show an error / offer a retry. A genuine miss — the server having no
1227
+ * record for a fresh thread — resolves normally and never reaches here; only a
1228
+ * thrown transport / authorize-gate error does.
1229
+ *
1230
+ * Skipped when the view unmounted (`!tailing`) or a `sendMessage` took
1231
+ * ownership while the hydrate GET was in flight, so a live run's state always
1232
+ * wins over a stale mount-time failure — same guard as the success path above.
1233
+ */
1234
+ private failHydration(cause: unknown): void {
1235
+ if (this.disposed || !this.tailing) return
1236
+ if (this.isLoading || this.abortController) return
1237
+ const error = cause instanceof Error ? cause : new Error(String(cause))
1238
+ // Mirror the send path: a BYOK key that is missing / locked must still
1239
+ // trigger the key-request flow on thread load, not just be reported.
1240
+ if (error instanceof ByokMissingError) {
1241
+ this.byok?.request(error.provider, 'missing')
1242
+ }
1243
+ if (error instanceof ByokBlockedError && error.reason === 'locked') {
1244
+ this.byok?.request(error.provider, 'locked')
1245
+ }
1246
+ this.hydrationError = error
1247
+ this.setStatus('error')
1248
+ this.setError(error)
1249
+ this.callbacksRef.current.onError(error)
1250
+ }
1251
+
1173
1252
  mountDevtools(): void {
1174
1253
  this.ensureThreadId()
1175
1254
  if (this.devtoolsMounted) {
@@ -1857,13 +1936,23 @@ export class ChatClient<
1857
1936
  }
1858
1937
 
1859
1938
  /**
1860
- * Consume chunks from the connection subscription.
1939
+ * Consume chunks from the connection subscription. Chunks are processed in
1940
+ * order; the loop yields to the host after each processing budget.
1861
1941
  */
1862
1942
  private async consumeSubscription(signal: AbortSignal): Promise<void> {
1863
1943
  const stream = this.connection.subscribe(signal)
1944
+ let chunkProcessingTime = 0
1864
1945
  for await (const chunk of stream) {
1865
1946
  if (signal.aborted) break
1866
- await this.processIncomingChunk(chunk)
1947
+ const startedAt = performance.now()
1948
+ this.processIncomingChunk(chunk)
1949
+ chunkProcessingTime += performance.now() - startedAt
1950
+ if (chunkProcessingTime < STREAM_PROCESSING_BUDGET_MS) continue
1951
+ chunkProcessingTime = 0
1952
+ // Skip the yield when the page is hidden. Browsers clamp timers there,
1953
+ // and that wait paces stream pull.
1954
+ if (typeof document !== 'undefined' && document.hidden) continue
1955
+ await yieldToHost()
1867
1956
  }
1868
1957
  }
1869
1958
 
@@ -1886,7 +1975,7 @@ export class ChatClient<
1886
1975
  * give up after {@link REJOIN_CONNECT_DEADLINE_MS} if no chunk arrives and
1887
1976
  * clear the dead pointer so it does not retry on the next load.
1888
1977
  *
1889
- * Replay chunks are processed WITHOUT the per-chunk yield the live path uses,
1978
+ * Replay chunks are processed WITHOUT the time-slice yield the live path uses,
1890
1979
  * so the buffered prefix snaps in and only the genuinely-live tail streams at
1891
1980
  * network speed — a reload looks like the run continued, not like it re-typed.
1892
1981
  */
@@ -1923,11 +2012,11 @@ export class ChatClient<
1923
2012
  attached = true
1924
2013
  clearTimeout(connectTimer)
1925
2014
  }
1926
- if (!rebuilt && REJOIN_REBUILD_TRIGGERS.has(chunk.type)) {
2015
+ if (!rebuilt && rebuildsAssistantMessage(chunk)) {
1927
2016
  rebuilt = true
1928
2017
  this.dropTrailingInFlightAssistant()
1929
2018
  }
1930
- await this.processIncomingChunk(chunk, { defer: false })
2019
+ this.processIncomingChunk(chunk)
1931
2020
  }
1932
2021
  // Same contract as `streamResponse`: client tools may finish (and
1933
2022
  // queue a resume) while `isLoading` is still true. Wait for them
@@ -1996,10 +2085,7 @@ export class ChatClient<
1996
2085
  }
1997
2086
  }
1998
2087
 
1999
- private async processIncomingChunk(
2000
- chunk: StreamChunk,
2001
- options?: { defer?: boolean },
2002
- ): Promise<void> {
2088
+ private processIncomingChunk(chunk: StreamChunk): void {
2003
2089
  chunk = restoreInboundChunk(chunk)
2004
2090
  if (
2005
2091
  chunk.type === 'RUN_ERROR' &&
@@ -2042,15 +2128,6 @@ export class ChatClient<
2042
2128
  this.syncSubagentHandles()
2043
2129
  this.updateRunLifecycle(chunk)
2044
2130
  this.observeInterruptState(chunk)
2045
- // Live path: yield a macrotask so the UI can paint. Skip when the page is
2046
- // hidden. Browsers clamp setTimeout there, and that wait paces stream pull.
2047
- // Replay passes defer: false so a backlog applies in one batch.
2048
- if (
2049
- options?.defer !== false &&
2050
- (typeof document === 'undefined' || !document.hidden)
2051
- ) {
2052
- await new Promise((resolve) => setTimeout(resolve, 0))
2053
- }
2054
2131
  this.resolveJoinedRun(chunk)
2055
2132
  }
2056
2133
 
@@ -2813,12 +2890,22 @@ export class ChatClient<
2813
2890
  continuationGeneration: number,
2814
2891
  context?: ChatClientRunEventContext,
2815
2892
  ): Promise<void> {
2816
- if (clientTool && result.state !== 'output-error') {
2893
+ if (result.state !== 'output-error') {
2817
2894
  try {
2818
- result = {
2819
- ...result,
2820
- output: this.validateClientToolOutput(clientTool, result.output),
2895
+ let output =
2896
+ clientTool?.outputSchema && isStandardSchema(clientTool.outputSchema)
2897
+ ? await this.validateClientToolOutput(clientTool, result.output)
2898
+ : result.output
2899
+ // Only an interrupt resume needs JSON output. The legacy continuation
2900
+ // keeps the raw value.
2901
+ if (
2902
+ this.interruptManager
2903
+ .getDescriptors()
2904
+ .some((interrupt) => interrupt.toolCallId === result.toolCallId)
2905
+ ) {
2906
+ output = cloneAndDeepFreezeJson(output)
2821
2907
  }
2908
+ result = { ...result, output }
2822
2909
  } catch (error: any) {
2823
2910
  result = {
2824
2911
  ...result,
@@ -2850,12 +2937,16 @@ export class ChatClient<
2850
2937
  )
2851
2938
  this.devtoolsBridge.emitSnapshot()
2852
2939
 
2853
- const resolvedViaInterrupt = this.interruptManager.resolveClientToolOutput(
2854
- result.toolCallId,
2940
+ const resolvedViaInterrupt =
2855
2941
  result.state === 'output-error'
2856
- ? { error: result.errorText || 'Tool execution failed' }
2857
- : result.output,
2858
- )
2942
+ ? this.interruptManager.resolveClientToolError(
2943
+ result.toolCallId,
2944
+ result.errorText || 'Tool execution failed',
2945
+ )
2946
+ : this.interruptManager.resolveClientToolOutput(
2947
+ result.toolCallId,
2948
+ result.output,
2949
+ )
2859
2950
  if (resolvedViaInterrupt) {
2860
2951
  // Interrupt manager stages/submits the resume batch (deferred until the
2861
2952
  // parent stream settles when still loading). Skip legacy continuation.
@@ -2875,15 +2966,20 @@ export class ChatClient<
2875
2966
  await this.checkForContinuation()
2876
2967
  }
2877
2968
 
2878
- private validateClientToolOutput(
2969
+ private async validateClientToolOutput(
2879
2970
  clientTool: AnyClientTool,
2880
- output: any,
2881
- ): any {
2882
- if (clientTool.outputSchema && isStandardSchema(clientTool.outputSchema)) {
2883
- return parseWithStandardSchema(clientTool.outputSchema, output)
2971
+ output: unknown,
2972
+ ): Promise<unknown> {
2973
+ const validation = await validateWithStandardSchema<unknown>(
2974
+ clientTool.outputSchema,
2975
+ output,
2976
+ )
2977
+ if (!validation.success) {
2978
+ throw new Error(
2979
+ validation.issues.map((issue) => issue.message).join(', '),
2980
+ )
2884
2981
  }
2885
-
2886
- return output
2982
+ return validation.data
2887
2983
  }
2888
2984
 
2889
2985
  /**
package/src/index.ts CHANGED
@@ -14,9 +14,16 @@ export type {
14
14
  } from './interrupt-manager'
15
15
  export { createMcpAppBridge } from './mcp-app-bridge'
16
16
  export type { McpAppBridge, CreateMcpAppBridgeOptions } from './mcp-app-bridge'
17
- export { registerWebMCPTools } from './web-mcp-tools'
17
+ export {
18
+ getWebMCPTools,
19
+ registerWebMCPTools,
20
+ subscribeWebMCPTools,
21
+ } from './web-mcp-tools'
18
22
  export type {
23
+ GetWebMCPToolsOptions,
19
24
  RegisterWebMCPToolsOptions,
25
+ SubscribeWebMCPToolsOptions,
26
+ WebMCPPageTool,
20
27
  WebMCPToolAnnotations,
21
28
  WebMCPToolOptions,
22
29
  WebMCPToolOptionsByName,
@@ -11,6 +11,7 @@ import {
11
11
  isStandardSchema,
12
12
  normalizeApprovalSchema,
13
13
  readInterruptBinding,
14
+ withTanstackMetadata,
14
15
  wrapGenericInterruptContinuation,
15
16
  } from '@tanstack/ai/client'
16
17
  import type {
@@ -124,11 +125,13 @@ function resolutionWithContinuation(
124
125
  const continuation = genericInterruptContinuationFromDescriptor(
125
126
  item.descriptor,
126
127
  )
127
- if (!continuation) return resolution
128
- return {
129
- ...resolution,
130
- metadata: wrapGenericInterruptContinuation(continuation),
128
+ if (continuation) {
129
+ return {
130
+ ...resolution,
131
+ metadata: wrapGenericInterruptContinuation(continuation),
132
+ }
131
133
  }
134
+ return resolution
132
135
  }
133
136
 
134
137
  function isRootResolvableInterrupt<
@@ -684,6 +687,25 @@ export class InterruptManager<
684
687
  }
685
688
 
686
689
  resolveClientToolOutput(toolCallId: string, output: unknown): boolean {
690
+ return this.resolveClientToolResult(toolCallId, {
691
+ state: 'output-available',
692
+ output,
693
+ })
694
+ }
695
+
696
+ resolveClientToolError(toolCallId: string, errorText: string): boolean {
697
+ return this.resolveClientToolResult(toolCallId, {
698
+ state: 'output-error',
699
+ errorText,
700
+ })
701
+ }
702
+
703
+ private resolveClientToolResult(
704
+ toolCallId: string,
705
+ result:
706
+ | { state: 'output-available'; output: unknown }
707
+ | { state: 'output-error'; errorText: string },
708
+ ): boolean {
687
709
  const item = this.items.find(
688
710
  (candidate) =>
689
711
  (candidate.kind === 'client-tool-execution' &&
@@ -695,10 +717,56 @@ export class InterruptManager<
695
717
  isLegacyClientToolMetadata(candidate.descriptor.metadata)),
696
718
  )
697
719
  if (!item) return false
698
- this.resolveItem(item.descriptor.id, output)
720
+ if (item.kind !== 'client-tool-execution') {
721
+ this.resolveItem(
722
+ item.descriptor.id,
723
+ result.state === 'output-error'
724
+ ? { error: result.errorText }
725
+ : result.output,
726
+ )
727
+ return true
728
+ }
729
+ if (result.state === 'output-available') {
730
+ this.resolveItem(item.descriptor.id, result.output)
731
+ return true
732
+ }
733
+ this.stageClientToolError(item, result.errorText)
699
734
  return true
700
735
  }
701
736
 
737
+ private stageClientToolError(
738
+ item: RuntimeInterrupt,
739
+ errorText: string,
740
+ ): void {
741
+ this.assertItemMutable()
742
+ this.invalidateRetry()
743
+ if (!item.canResolve) {
744
+ item.status = 'error'
745
+ item.error = this.itemError(
746
+ item.descriptor.id,
747
+ 'invalid-response-schema',
748
+ 'The interrupt response schema is invalid and cannot be resolved.',
749
+ )
750
+ this.publish()
751
+ return
752
+ }
753
+ item.validationGeneration++
754
+ item.resolution = cloneAndDeepFreezeJson(
755
+ withTanstackMetadata(
756
+ resolutionWithContinuation(item, {
757
+ interruptId: item.descriptor.id,
758
+ status: 'resolved',
759
+ payload: { error: errorText },
760
+ }),
761
+ { state: 'output-error' },
762
+ ),
763
+ )
764
+ item.status = 'staged'
765
+ item.error = undefined
766
+ this.publish()
767
+ this.maybeSubmit()
768
+ }
769
+
702
770
  resolveToolApprovalDecision(interruptId: string, approved: boolean): boolean {
703
771
  const item = this.items.find(
704
772
  (candidate) =>
@@ -1279,11 +1347,28 @@ export class InterruptManager<
1279
1347
  : preserveInput(validation)
1280
1348
  }
1281
1349
  if (item.kind === 'client-tool-execution') {
1282
- return validateWithSchema(
1350
+ const validation = validateWithSchema(
1283
1351
  item.tool?.outputSchema,
1284
1352
  payload,
1285
1353
  'invalid-tool-output',
1286
1354
  )
1355
+ const canonicalize = (result: ValidationResult): ValidationResult => {
1356
+ if (!('valid' in result)) return result
1357
+ try {
1358
+ return {
1359
+ valid: true,
1360
+ payload: cloneAndDeepFreezeJson(result.payload),
1361
+ }
1362
+ } catch (error) {
1363
+ return {
1364
+ code: 'invalid-tool-output',
1365
+ message: error instanceof Error ? error.message : String(error),
1366
+ }
1367
+ }
1368
+ }
1369
+ return isPromiseLike(validation)
1370
+ ? Promise.resolve(validation).then(canonicalize)
1371
+ : canonicalize(validation)
1287
1372
  }
1288
1373
  return this.validateApprovalCandidate(item, payload)
1289
1374
  }
package/src/types.ts CHANGED
@@ -24,6 +24,7 @@ import type {
24
24
  StructuredOutputPart,
25
25
  SubagentHandleData,
26
26
  SubagentStatus,
27
+ ToolResultOutcome,
27
28
  UIResourcePart,
28
29
  VideoPart,
29
30
  } from '@tanstack/ai/client'
@@ -618,6 +619,8 @@ export interface ToolResultPart {
618
619
  toolCallId: string
619
620
  content: string | Array<ContentPart>
620
621
  state: ToolResultState
622
+ /** Set when the user or middleware cancelled or denied the tool call; state remains `error`. */
623
+ outcome?: ToolResultOutcome
621
624
  error?: string // Error message if state is "error"
622
625
  metadata?: Record<string, unknown>
623
626
  createdAt?: Date
@@ -209,3 +209,184 @@ export async function registerWebMCPTools<
209
209
  throw error
210
210
  }
211
211
  }
212
+
213
+ /** A tool that a page registered with WebMCP, as `getTools()` returns it. */
214
+ export interface WebMCPPageTool {
215
+ name: string
216
+ title?: string
217
+ description: string
218
+ /** A JSON Schema object for the tool input. */
219
+ inputSchema?: object
220
+ /** The origin of the document that registered the tool. */
221
+ origin: string
222
+ annotations?: WebMCPToolAnnotations
223
+ }
224
+
225
+ interface WebMCPToolReader {
226
+ getTools: () => Promise<Array<WebMCPPageTool>>
227
+ executeTool: (
228
+ tool: WebMCPPageTool,
229
+ input: unknown,
230
+ options: { signal?: AbortSignal },
231
+ ) => Promise<string>
232
+ addEventListener: EventTarget['addEventListener']
233
+ removeEventListener: EventTarget['removeEventListener']
234
+ }
235
+
236
+ /** Options for {@link getWebMCPTools}. */
237
+ export interface GetWebMCPToolsOptions {
238
+ /** Return `false` to skip a tool. */
239
+ filter?: (tool: WebMCPPageTool) => boolean
240
+ }
241
+
242
+ /** Options for {@link subscribeWebMCPTools}. */
243
+ export interface SubscribeWebMCPToolsOptions extends GetWebMCPToolsOptions {
244
+ /** Stops the subscription when it aborts. */
245
+ signal: AbortSignal
246
+ /** Receives a failure from the WebMCP `getTools()` call. */
247
+ onError?: (error: unknown) => void
248
+ }
249
+
250
+ function isWebMCPToolReader(value: unknown): value is WebMCPToolReader {
251
+ return (
252
+ value !== null &&
253
+ typeof value === 'object' &&
254
+ 'getTools' in value &&
255
+ typeof value.getTools === 'function' &&
256
+ 'executeTool' in value &&
257
+ typeof value.executeTool === 'function' &&
258
+ 'addEventListener' in value &&
259
+ typeof value.addEventListener === 'function' &&
260
+ 'removeEventListener' in value &&
261
+ typeof value.removeEventListener === 'function'
262
+ )
263
+ }
264
+
265
+ function getWebMCPToolReader() {
266
+ if (
267
+ typeof document === 'undefined' ||
268
+ (typeof isSecureContext !== 'undefined' && !isSecureContext) ||
269
+ !('modelContext' in document) ||
270
+ !isWebMCPToolReader(document.modelContext)
271
+ ) {
272
+ return undefined
273
+ }
274
+ return document.modelContext
275
+ }
276
+
277
+ function parseToolResult(result: string): unknown {
278
+ try {
279
+ return JSON.parse(result)
280
+ } catch {
281
+ return result
282
+ }
283
+ }
284
+
285
+ async function readWebMCPTools(
286
+ reader: WebMCPToolReader,
287
+ options: GetWebMCPToolsOptions | undefined,
288
+ ): Promise<Array<AnyClientTool>> {
289
+ const pageTools = await reader.getTools()
290
+ const names = new Set<string>()
291
+ return pageTools
292
+ .filter((tool) => options?.filter?.(tool) ?? true)
293
+ .map((tool) => {
294
+ if (names.has(tool.name)) {
295
+ throw new Error(
296
+ `Duplicate WebMCP tool name "${tool.name}". Use a filter or register tools with unique names.`,
297
+ )
298
+ }
299
+ names.add(tool.name)
300
+ return {
301
+ __toolSide: 'client' as const,
302
+ name: tool.name,
303
+ description: tool.description,
304
+ inputSchema: tool.inputSchema ?? { type: 'object' },
305
+ async execute(input: unknown, context?: { abortSignal?: AbortSignal }) {
306
+ const result = await reader.executeTool(
307
+ tool,
308
+ input,
309
+ context?.abortSignal ? { signal: context.abortSignal } : {},
310
+ )
311
+ return parseToolResult(result)
312
+ },
313
+ }
314
+ })
315
+ }
316
+
317
+ /**
318
+ * Reads the WebMCP tools on the page and returns them as client tools.
319
+ *
320
+ * Pass the result to a chat as `tools`. Each tool runs through the WebMCP
321
+ * `executeTool()` call. Unsupported browsers and server environments return
322
+ * an empty array.
323
+ *
324
+ * @param options - A filter that skips tools.
325
+ *
326
+ * @example
327
+ * ```ts
328
+ * const tools = await getWebMCPTools({
329
+ * filter: (tool) => tool.origin === location.origin,
330
+ * })
331
+ * ```
332
+ */
333
+ export async function getWebMCPTools(
334
+ options?: GetWebMCPToolsOptions,
335
+ ): Promise<Array<AnyClientTool>> {
336
+ const reader = getWebMCPToolReader()
337
+ return reader ? readWebMCPTools(reader, options) : []
338
+ }
339
+
340
+ /**
341
+ * Calls `listener` with the page WebMCP tools now and after each
342
+ * `toolchange` event, until `options.signal` aborts.
343
+ *
344
+ * Unsupported browsers and server environments call `listener` once with an
345
+ * empty array. When a read fails, `options.onError` gets the error and the
346
+ * listener keeps the last list.
347
+ *
348
+ * @param listener - Receives the current client tools.
349
+ * @param options - The subscription signal, a filter, and an error callback.
350
+ *
351
+ * @example
352
+ * ```ts
353
+ * const controller = new AbortController()
354
+ * subscribeWebMCPTools((tools) => client.updateOptions({ tools }), {
355
+ * signal: controller.signal,
356
+ * })
357
+ * ```
358
+ */
359
+ export function subscribeWebMCPTools(
360
+ listener: (tools: Array<AnyClientTool>) => void,
361
+ options: SubscribeWebMCPToolsOptions,
362
+ ): void {
363
+ if (options.signal.aborted) return
364
+ const reader = getWebMCPToolReader()
365
+ if (!reader) {
366
+ listener([])
367
+ return
368
+ }
369
+
370
+ let latestRead = 0
371
+ const refresh = () => {
372
+ const read = ++latestRead
373
+ const isCurrent = () => read === latestRead && !options.signal.aborted
374
+ readWebMCPTools(reader, options).then(
375
+ (tools) => {
376
+ if (isCurrent()) listener(tools)
377
+ },
378
+ (error: unknown) => {
379
+ if (isCurrent()) options.onError?.(error)
380
+ },
381
+ )
382
+ }
383
+
384
+ // Remove the listener by hand: Zone.js breaks the `signal` listener option.
385
+ reader.addEventListener('toolchange', refresh)
386
+ options.signal.addEventListener(
387
+ 'abort',
388
+ () => reader.removeEventListener('toolchange', refresh),
389
+ { once: true },
390
+ )
391
+ refresh()
392
+ }