@tanstack/ai-client 0.34.0 → 0.35.1

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.
@@ -5,8 +5,8 @@ export { InterruptManager } from './interrupt-manager.js';
5
5
  export type { InterruptManagerHydration, InterruptManagerOptions, InterruptManagerSubmission, } from './interrupt-manager.js';
6
6
  export { createMcpAppBridge } from './mcp-app-bridge.js';
7
7
  export type { McpAppBridge, CreateMcpAppBridgeOptions } from './mcp-app-bridge.js';
8
- export { registerWebMCPTools } from './web-mcp-tools.js';
9
- export type { RegisterWebMCPToolsOptions, WebMCPToolAnnotations, WebMCPToolOptions, WebMCPToolOptionsByName, } from './web-mcp-tools.js';
8
+ export { getWebMCPTools, registerWebMCPTools, subscribeWebMCPTools, } from './web-mcp-tools.js';
9
+ export type { GetWebMCPToolsOptions, RegisterWebMCPToolsOptions, SubscribeWebMCPToolsOptions, WebMCPPageTool, WebMCPToolAnnotations, WebMCPToolOptions, WebMCPToolOptionsByName, } from './web-mcp-tools.js';
10
10
  export { RealtimeClient } from './realtime-client.js';
11
11
  export { GenerationClient } from './generation-client.js';
12
12
  export { VideoGenerationClient } from './video-generation-client.js';
package/dist/esm/index.js CHANGED
@@ -4,7 +4,7 @@ import { DurableStreamIncompleteError, StreamReconnectLimitError, StreamTruncate
4
4
  import { InterruptManager } from "./interrupt-manager.js";
5
5
  import { ChatClient } from "./chat-client.js";
6
6
  import { createMcpAppBridge } from "./mcp-app-bridge.js";
7
- import { registerWebMCPTools } from "./web-mcp-tools.js";
7
+ import { getWebMCPTools, registerWebMCPTools, subscribeWebMCPTools } from "./web-mcp-tools.js";
8
8
  import { RealtimeClient } from "./realtime-client.js";
9
9
  import { GENERATION_EVENTS } from "./generation-types.js";
10
10
  import { GenerationClient } from "./generation-client.js";
@@ -14,4 +14,4 @@ import { clientTools, createChatClientOptions } from "./types.js";
14
14
  import { StorageUnavailableError, indexedDBPersistence, localStoragePersistence, sessionStoragePersistence } from "./storage-adapters.js";
15
15
  import { createAIDevtoolsGenerationPreview } from "./devtools.js";
16
16
  import { BatchStrategy, CompositeStrategy, ImmediateStrategy, PartialJSONParser, PunctuationStrategy, StreamProcessor, WordBoundaryStrategy, convertMessagesToModelMessages, defaultJSONParser, generateMessageId, modelMessageToUIMessage, modelMessagesToUIMessages, normalizeToUIMessage, parsePartialJSON, uiMessageToModelMessages } from "@tanstack/ai/client";
17
- export { AudioRecorder, BatchStrategy, ChatClient, CompositeStrategy, DurableStreamIncompleteError, GENERATION_EVENTS, GenerationClient, ImmediateStrategy, InterruptManager, PartialJSONParser, PunctuationStrategy, RealtimeClient, StorageUnavailableError, StreamProcessor, StreamReconnectLimitError, StreamTruncatedError, UnsupportedResponseStreamError, VideoGenerationClient, WordBoundaryStrategy, clientTools, convertMessagesToModelMessages, createAIDevtoolsGenerationPreview, createChatClientOptions, createMcpAppBridge, defaultJSONParser, fetchHttpStream, fetchServerSentEvents, generateMessageId, indexedDBPersistence, localStoragePersistence, modelMessageToUIMessage, modelMessagesToUIMessages, normalizeToUIMessage, parsePartialJSON, reconstructAudioResult, reconstructImageResult, reconstructSpeechResult, reconstructSummarizeResult, reconstructTranscriptionResult, registerWebMCPTools, rpcStream, sessionStoragePersistence, stream, uiMessageToModelMessages, webSocket, xhrHttpStream, xhrServerSentEvents };
17
+ export { AudioRecorder, BatchStrategy, ChatClient, CompositeStrategy, DurableStreamIncompleteError, GENERATION_EVENTS, GenerationClient, ImmediateStrategy, InterruptManager, PartialJSONParser, PunctuationStrategy, RealtimeClient, StorageUnavailableError, StreamProcessor, StreamReconnectLimitError, StreamTruncatedError, UnsupportedResponseStreamError, VideoGenerationClient, WordBoundaryStrategy, clientTools, convertMessagesToModelMessages, createAIDevtoolsGenerationPreview, createChatClientOptions, createMcpAppBridge, defaultJSONParser, fetchHttpStream, fetchServerSentEvents, generateMessageId, getWebMCPTools, indexedDBPersistence, localStoragePersistence, modelMessageToUIMessage, modelMessagesToUIMessages, normalizeToUIMessage, parsePartialJSON, reconstructAudioResult, reconstructImageResult, reconstructSpeechResult, reconstructSummarizeResult, reconstructTranscriptionResult, registerWebMCPTools, rpcStream, sessionStoragePersistence, stream, subscribeWebMCPTools, uiMessageToModelMessages, webSocket, xhrHttpStream, xhrServerSentEvents };
@@ -47,3 +47,63 @@ export type RegisterWebMCPToolsOptions<TTools extends ReadonlyArray<AnyClientToo
47
47
  * ```
48
48
  */
49
49
  export declare function registerWebMCPTools<const TTools extends ReadonlyArray<AnyClientTool>, TContext = InferredClientContext<TTools>>(tools: TTools, options: RegisterWebMCPToolsOptions<TTools, TContext>): Promise<void>;
50
+ /** A tool that a page registered with WebMCP, as `getTools()` returns it. */
51
+ export interface WebMCPPageTool {
52
+ name: string;
53
+ title?: string;
54
+ description: string;
55
+ /** A JSON Schema object for the tool input. */
56
+ inputSchema?: object;
57
+ /** The origin of the document that registered the tool. */
58
+ origin: string;
59
+ annotations?: WebMCPToolAnnotations;
60
+ }
61
+ /** Options for {@link getWebMCPTools}. */
62
+ export interface GetWebMCPToolsOptions {
63
+ /** Return `false` to skip a tool. */
64
+ filter?: (tool: WebMCPPageTool) => boolean;
65
+ }
66
+ /** Options for {@link subscribeWebMCPTools}. */
67
+ export interface SubscribeWebMCPToolsOptions extends GetWebMCPToolsOptions {
68
+ /** Stops the subscription when it aborts. */
69
+ signal: AbortSignal;
70
+ /** Receives a failure from the WebMCP `getTools()` call. */
71
+ onError?: (error: unknown) => void;
72
+ }
73
+ /**
74
+ * Reads the WebMCP tools on the page and returns them as client tools.
75
+ *
76
+ * Pass the result to a chat as `tools`. Each tool runs through the WebMCP
77
+ * `executeTool()` call. Unsupported browsers and server environments return
78
+ * an empty array.
79
+ *
80
+ * @param options - A filter that skips tools.
81
+ *
82
+ * @example
83
+ * ```ts
84
+ * const tools = await getWebMCPTools({
85
+ * filter: (tool) => tool.origin === location.origin,
86
+ * })
87
+ * ```
88
+ */
89
+ export declare function getWebMCPTools(options?: GetWebMCPToolsOptions): Promise<Array<AnyClientTool>>;
90
+ /**
91
+ * Calls `listener` with the page WebMCP tools now and after each
92
+ * `toolchange` event, until `options.signal` aborts.
93
+ *
94
+ * Unsupported browsers and server environments call `listener` once with an
95
+ * empty array. When a read fails, `options.onError` gets the error and the
96
+ * listener keeps the last list.
97
+ *
98
+ * @param listener - Receives the current client tools.
99
+ * @param options - The subscription signal, a filter, and an error callback.
100
+ *
101
+ * @example
102
+ * ```ts
103
+ * const controller = new AbortController()
104
+ * subscribeWebMCPTools((tools) => client.updateOptions({ tools }), {
105
+ * signal: controller.signal,
106
+ * })
107
+ * ```
108
+ */
109
+ export declare function subscribeWebMCPTools(listener: (tools: Array<AnyClientTool>) => void, options: SubscribeWebMCPToolsOptions): void;
@@ -74,7 +74,98 @@ async function registerWebMCPTools(tools, options) {
74
74
  throw error;
75
75
  }
76
76
  }
77
+ function isWebMCPToolReader(value) {
78
+ return value !== null && typeof value === "object" && "getTools" in value && typeof value.getTools === "function" && "executeTool" in value && typeof value.executeTool === "function" && "addEventListener" in value && typeof value.addEventListener === "function" && "removeEventListener" in value && typeof value.removeEventListener === "function";
79
+ }
80
+ function getWebMCPToolReader() {
81
+ if (typeof document === "undefined" || typeof isSecureContext !== "undefined" && !isSecureContext || !("modelContext" in document) || !isWebMCPToolReader(document.modelContext)) return;
82
+ return document.modelContext;
83
+ }
84
+ function parseToolResult(result) {
85
+ try {
86
+ return JSON.parse(result);
87
+ } catch {
88
+ return result;
89
+ }
90
+ }
91
+ async function readWebMCPTools(reader, options) {
92
+ const pageTools = await reader.getTools();
93
+ const names = /* @__PURE__ */ new Set();
94
+ return pageTools.filter((tool) => options?.filter?.(tool) ?? true).map((tool) => {
95
+ if (names.has(tool.name)) throw new Error(`Duplicate WebMCP tool name "${tool.name}". Use a filter or register tools with unique names.`);
96
+ names.add(tool.name);
97
+ return {
98
+ __toolSide: "client",
99
+ name: tool.name,
100
+ description: tool.description,
101
+ inputSchema: tool.inputSchema ?? { type: "object" },
102
+ async execute(input, context) {
103
+ return parseToolResult(await reader.executeTool(tool, input, context?.abortSignal ? { signal: context.abortSignal } : {}));
104
+ }
105
+ };
106
+ });
107
+ }
108
+ /**
109
+ * Reads the WebMCP tools on the page and returns them as client tools.
110
+ *
111
+ * Pass the result to a chat as `tools`. Each tool runs through the WebMCP
112
+ * `executeTool()` call. Unsupported browsers and server environments return
113
+ * an empty array.
114
+ *
115
+ * @param options - A filter that skips tools.
116
+ *
117
+ * @example
118
+ * ```ts
119
+ * const tools = await getWebMCPTools({
120
+ * filter: (tool) => tool.origin === location.origin,
121
+ * })
122
+ * ```
123
+ */
124
+ async function getWebMCPTools(options) {
125
+ const reader = getWebMCPToolReader();
126
+ return reader ? readWebMCPTools(reader, options) : [];
127
+ }
128
+ /**
129
+ * Calls `listener` with the page WebMCP tools now and after each
130
+ * `toolchange` event, until `options.signal` aborts.
131
+ *
132
+ * Unsupported browsers and server environments call `listener` once with an
133
+ * empty array. When a read fails, `options.onError` gets the error and the
134
+ * listener keeps the last list.
135
+ *
136
+ * @param listener - Receives the current client tools.
137
+ * @param options - The subscription signal, a filter, and an error callback.
138
+ *
139
+ * @example
140
+ * ```ts
141
+ * const controller = new AbortController()
142
+ * subscribeWebMCPTools((tools) => client.updateOptions({ tools }), {
143
+ * signal: controller.signal,
144
+ * })
145
+ * ```
146
+ */
147
+ function subscribeWebMCPTools(listener, options) {
148
+ if (options.signal.aborted) return;
149
+ const reader = getWebMCPToolReader();
150
+ if (!reader) {
151
+ listener([]);
152
+ return;
153
+ }
154
+ let latestRead = 0;
155
+ const refresh = () => {
156
+ const read = ++latestRead;
157
+ const isCurrent = () => read === latestRead && !options.signal.aborted;
158
+ readWebMCPTools(reader, options).then((tools) => {
159
+ if (isCurrent()) listener(tools);
160
+ }, (error) => {
161
+ if (isCurrent()) options.onError?.(error);
162
+ });
163
+ };
164
+ reader.addEventListener("toolchange", refresh);
165
+ options.signal.addEventListener("abort", () => reader.removeEventListener("toolchange", refresh), { once: true });
166
+ refresh();
167
+ }
77
168
  //#endregion
78
- export { registerWebMCPTools };
169
+ export { getWebMCPTools, registerWebMCPTools, subscribeWebMCPTools };
79
170
 
80
171
  //# sourceMappingURL=web-mcp-tools.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"web-mcp-tools.js","names":[],"sources":["../../src/web-mcp-tools.ts"],"sourcesContent":["import {\n convertSchemaToJsonSchema,\n validateWithStandardSchema,\n} from '@tanstack/ai/client'\nimport type { AnyClientTool } from '@tanstack/ai/client'\nimport type {\n ClientContextOptionFromTools,\n InferredClientContext,\n} from './types'\n\ninterface WebMCPTool {\n name: string\n title?: string\n description: string\n inputSchema?: object\n annotations?: WebMCPToolAnnotations\n execute: (input: object, options: { signal: AbortSignal }) => Promise<unknown>\n}\n\ninterface WebMCPModelContext {\n registerTool: (\n tool: WebMCPTool,\n options: { signal: AbortSignal },\n ) => Promise<void>\n}\n\n/** WebMCP behavior hints for one registered tool. */\nexport interface WebMCPToolAnnotations {\n /** Indicates that the tool does not modify state. */\n readOnlyHint?: boolean\n /** Indicates that the tool can return content that the application does not trust. */\n untrustedContentHint?: boolean\n}\n\n/** Display and behavior options for one WebMCP tool. */\nexport interface WebMCPToolOptions {\n /** A human-readable title for browser user interfaces. */\n title?: string\n /** Optional behavior hints for browser agents. */\n annotations?: WebMCPToolAnnotations\n}\n\n/** WebMCP options keyed by the inferred names in a client tool list. */\nexport type WebMCPToolOptionsByName<\n TTools extends ReadonlyArray<AnyClientTool>,\n> = Partial<{\n [TName in TTools[number]['name']]: WebMCPToolOptions\n}>\n\n/**\n * Options for {@link registerWebMCPTools}.\n *\n * The signal controls the registration lifetime. Context is required when a\n * client tool declares a required runtime context.\n */\nexport type RegisterWebMCPToolsOptions<\n TTools extends ReadonlyArray<AnyClientTool>,\n TContext = InferredClientContext<TTools>,\n> = {\n /** Removes all tools from this call when the signal aborts. */\n signal: AbortSignal\n /** Per-tool display and behavior options. */\n toolOptions?: WebMCPToolOptionsByName<TTools>\n} & ClientContextOptionFromTools<TTools, TContext>\n\nfunction isWebMCPModelContext(value: unknown): value is WebMCPModelContext {\n return (\n value !== null &&\n typeof value === 'object' &&\n 'registerTool' in value &&\n typeof value.registerTool === 'function'\n )\n}\n\nfunction getToolOptions<TName extends string>(\n toolOptions: Partial<Record<TName, WebMCPToolOptions>> | undefined,\n name: TName,\n) {\n return toolOptions?.[name]\n}\n\nasync function validateSchemaValue(schema: unknown, value: unknown) {\n const result = await validateWithStandardSchema(schema, value)\n if (result.success) {\n return result.data\n }\n\n throw new Error(\n `Validation failed: ${result.issues.map((issue) => issue.message).join(', ')}`,\n )\n}\n\n/**\n * Registers executable TanStack client tools with the browser WebMCP API.\n *\n * Unsupported browsers and server environments resolve without registration.\n * Abort `options.signal` to remove every tool registered by this call.\n *\n * @param tools - The executable client tools to expose through WebMCP.\n * @param options - The registration signal, runtime context, and per-tool options.\n *\n * @example\n * ```ts\n * const controller = new AbortController()\n * await registerWebMCPTools(tools, { signal: controller.signal })\n * controller.abort()\n * ```\n */\nexport async function registerWebMCPTools<\n const TTools extends ReadonlyArray<AnyClientTool>,\n TContext = InferredClientContext<TTools>,\n>(tools: TTools, options: RegisterWebMCPToolsOptions<TTools, TContext>) {\n if (\n typeof document === 'undefined' ||\n (typeof isSecureContext !== 'undefined' && !isSecureContext) ||\n !('modelContext' in document) ||\n !isWebMCPModelContext(document.modelContext)\n ) {\n return\n }\n if (tools.length === 0) {\n return\n }\n\n const names = new Set<string>()\n const webMCPTools = tools.map((tool) => {\n if (!/^[A-Za-z0-9_.-]{1,128}$/.test(tool.name)) {\n throw new Error(\n `WebMCP tool name \"${tool.name}\" must contain 1 to 128 ASCII letters, numbers, underscores, hyphens, or periods.`,\n )\n }\n if (names.has(tool.name)) {\n throw new Error(`Duplicate WebMCP tool name \"${tool.name}\".`)\n }\n if (tool.description.trim() === '') {\n throw new Error(`WebMCP tool \"${tool.name}\" must have a description.`)\n }\n if (typeof tool.execute !== 'function') {\n throw new Error(\n `WebMCP tool \"${tool.name}\" must have an execute handler.`,\n )\n }\n if (tool.needsApproval === true) {\n throw new Error(\n `WebMCP tool \"${tool.name}\" cannot use needsApproval: true.`,\n )\n }\n\n names.add(tool.name)\n const toolOptions = getToolOptions(options.toolOptions, tool.name)\n const inputSchema = tool.inputSchema\n const outputSchema = tool.outputSchema\n const convertedInputSchema = convertSchemaToJsonSchema(inputSchema)\n const inputSchemaType = convertedInputSchema?.type\n const requiresNonObjectInput =\n (typeof inputSchemaType === 'string' && inputSchemaType !== 'object') ||\n (Array.isArray(inputSchemaType) && !inputSchemaType.includes('object'))\n if (requiresNonObjectInput) {\n throw new Error(\n `WebMCP tool \"${tool.name}\" input schema must accept an object.`,\n )\n }\n const execute = tool.execute\n\n return {\n name: tool.name,\n description: tool.description,\n ...(toolOptions?.title !== undefined ? { title: toolOptions.title } : {}),\n ...(convertedInputSchema !== undefined\n ? { inputSchema: convertedInputSchema }\n : {}),\n ...(toolOptions?.annotations !== undefined\n ? { annotations: toolOptions.annotations }\n : {}),\n async execute(\n input: object,\n executionOptions?: { signal?: AbortSignal },\n ) {\n const validatedInput = await validateSchemaValue(inputSchema, input)\n const output = await execute(validatedInput, {\n abortSignal: executionOptions?.signal,\n context: options.context,\n emitCustomEvent() {},\n })\n return validateSchemaValue(outputSchema, output)\n },\n }\n })\n\n const registrationController = new AbortController()\n const abortRegistration = () =>\n registrationController.abort(options.signal.reason)\n\n if (options.signal.aborted) {\n abortRegistration()\n } else {\n options.signal.addEventListener('abort', abortRegistration, { once: true })\n }\n\n try {\n for (const tool of webMCPTools) {\n await document.modelContext.registerTool(tool, {\n signal: registrationController.signal,\n })\n }\n } catch (error) {\n registrationController.abort(error)\n options.signal.removeEventListener('abort', abortRegistration)\n throw error\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"web-mcp-tools.js","names":[],"sources":["../../src/web-mcp-tools.ts"],"sourcesContent":["import {\n convertSchemaToJsonSchema,\n validateWithStandardSchema,\n} from '@tanstack/ai/client'\nimport type { AnyClientTool } from '@tanstack/ai/client'\nimport type {\n ClientContextOptionFromTools,\n InferredClientContext,\n} from './types'\n\ninterface WebMCPTool {\n name: string\n title?: string\n description: string\n inputSchema?: object\n annotations?: WebMCPToolAnnotations\n execute: (input: object, options: { signal: AbortSignal }) => Promise<unknown>\n}\n\ninterface WebMCPModelContext {\n registerTool: (\n tool: WebMCPTool,\n options: { signal: AbortSignal },\n ) => Promise<void>\n}\n\n/** WebMCP behavior hints for one registered tool. */\nexport interface WebMCPToolAnnotations {\n /** Indicates that the tool does not modify state. */\n readOnlyHint?: boolean\n /** Indicates that the tool can return content that the application does not trust. */\n untrustedContentHint?: boolean\n}\n\n/** Display and behavior options for one WebMCP tool. */\nexport interface WebMCPToolOptions {\n /** A human-readable title for browser user interfaces. */\n title?: string\n /** Optional behavior hints for browser agents. */\n annotations?: WebMCPToolAnnotations\n}\n\n/** WebMCP options keyed by the inferred names in a client tool list. */\nexport type WebMCPToolOptionsByName<\n TTools extends ReadonlyArray<AnyClientTool>,\n> = Partial<{\n [TName in TTools[number]['name']]: WebMCPToolOptions\n}>\n\n/**\n * Options for {@link registerWebMCPTools}.\n *\n * The signal controls the registration lifetime. Context is required when a\n * client tool declares a required runtime context.\n */\nexport type RegisterWebMCPToolsOptions<\n TTools extends ReadonlyArray<AnyClientTool>,\n TContext = InferredClientContext<TTools>,\n> = {\n /** Removes all tools from this call when the signal aborts. */\n signal: AbortSignal\n /** Per-tool display and behavior options. */\n toolOptions?: WebMCPToolOptionsByName<TTools>\n} & ClientContextOptionFromTools<TTools, TContext>\n\nfunction isWebMCPModelContext(value: unknown): value is WebMCPModelContext {\n return (\n value !== null &&\n typeof value === 'object' &&\n 'registerTool' in value &&\n typeof value.registerTool === 'function'\n )\n}\n\nfunction getToolOptions<TName extends string>(\n toolOptions: Partial<Record<TName, WebMCPToolOptions>> | undefined,\n name: TName,\n) {\n return toolOptions?.[name]\n}\n\nasync function validateSchemaValue(schema: unknown, value: unknown) {\n const result = await validateWithStandardSchema(schema, value)\n if (result.success) {\n return result.data\n }\n\n throw new Error(\n `Validation failed: ${result.issues.map((issue) => issue.message).join(', ')}`,\n )\n}\n\n/**\n * Registers executable TanStack client tools with the browser WebMCP API.\n *\n * Unsupported browsers and server environments resolve without registration.\n * Abort `options.signal` to remove every tool registered by this call.\n *\n * @param tools - The executable client tools to expose through WebMCP.\n * @param options - The registration signal, runtime context, and per-tool options.\n *\n * @example\n * ```ts\n * const controller = new AbortController()\n * await registerWebMCPTools(tools, { signal: controller.signal })\n * controller.abort()\n * ```\n */\nexport async function registerWebMCPTools<\n const TTools extends ReadonlyArray<AnyClientTool>,\n TContext = InferredClientContext<TTools>,\n>(tools: TTools, options: RegisterWebMCPToolsOptions<TTools, TContext>) {\n if (\n typeof document === 'undefined' ||\n (typeof isSecureContext !== 'undefined' && !isSecureContext) ||\n !('modelContext' in document) ||\n !isWebMCPModelContext(document.modelContext)\n ) {\n return\n }\n if (tools.length === 0) {\n return\n }\n\n const names = new Set<string>()\n const webMCPTools = tools.map((tool) => {\n if (!/^[A-Za-z0-9_.-]{1,128}$/.test(tool.name)) {\n throw new Error(\n `WebMCP tool name \"${tool.name}\" must contain 1 to 128 ASCII letters, numbers, underscores, hyphens, or periods.`,\n )\n }\n if (names.has(tool.name)) {\n throw new Error(`Duplicate WebMCP tool name \"${tool.name}\".`)\n }\n if (tool.description.trim() === '') {\n throw new Error(`WebMCP tool \"${tool.name}\" must have a description.`)\n }\n if (typeof tool.execute !== 'function') {\n throw new Error(\n `WebMCP tool \"${tool.name}\" must have an execute handler.`,\n )\n }\n if (tool.needsApproval === true) {\n throw new Error(\n `WebMCP tool \"${tool.name}\" cannot use needsApproval: true.`,\n )\n }\n\n names.add(tool.name)\n const toolOptions = getToolOptions(options.toolOptions, tool.name)\n const inputSchema = tool.inputSchema\n const outputSchema = tool.outputSchema\n const convertedInputSchema = convertSchemaToJsonSchema(inputSchema)\n const inputSchemaType = convertedInputSchema?.type\n const requiresNonObjectInput =\n (typeof inputSchemaType === 'string' && inputSchemaType !== 'object') ||\n (Array.isArray(inputSchemaType) && !inputSchemaType.includes('object'))\n if (requiresNonObjectInput) {\n throw new Error(\n `WebMCP tool \"${tool.name}\" input schema must accept an object.`,\n )\n }\n const execute = tool.execute\n\n return {\n name: tool.name,\n description: tool.description,\n ...(toolOptions?.title !== undefined ? { title: toolOptions.title } : {}),\n ...(convertedInputSchema !== undefined\n ? { inputSchema: convertedInputSchema }\n : {}),\n ...(toolOptions?.annotations !== undefined\n ? { annotations: toolOptions.annotations }\n : {}),\n async execute(\n input: object,\n executionOptions?: { signal?: AbortSignal },\n ) {\n const validatedInput = await validateSchemaValue(inputSchema, input)\n const output = await execute(validatedInput, {\n abortSignal: executionOptions?.signal,\n context: options.context,\n emitCustomEvent() {},\n })\n return validateSchemaValue(outputSchema, output)\n },\n }\n })\n\n const registrationController = new AbortController()\n const abortRegistration = () =>\n registrationController.abort(options.signal.reason)\n\n if (options.signal.aborted) {\n abortRegistration()\n } else {\n options.signal.addEventListener('abort', abortRegistration, { once: true })\n }\n\n try {\n for (const tool of webMCPTools) {\n await document.modelContext.registerTool(tool, {\n signal: registrationController.signal,\n })\n }\n } catch (error) {\n registrationController.abort(error)\n options.signal.removeEventListener('abort', abortRegistration)\n throw error\n }\n}\n\n/** A tool that a page registered with WebMCP, as `getTools()` returns it. */\nexport interface WebMCPPageTool {\n name: string\n title?: string\n description: string\n /** A JSON Schema object for the tool input. */\n inputSchema?: object\n /** The origin of the document that registered the tool. */\n origin: string\n annotations?: WebMCPToolAnnotations\n}\n\ninterface WebMCPToolReader {\n getTools: () => Promise<Array<WebMCPPageTool>>\n executeTool: (\n tool: WebMCPPageTool,\n input: unknown,\n options: { signal?: AbortSignal },\n ) => Promise<string>\n addEventListener: EventTarget['addEventListener']\n removeEventListener: EventTarget['removeEventListener']\n}\n\n/** Options for {@link getWebMCPTools}. */\nexport interface GetWebMCPToolsOptions {\n /** Return `false` to skip a tool. */\n filter?: (tool: WebMCPPageTool) => boolean\n}\n\n/** Options for {@link subscribeWebMCPTools}. */\nexport interface SubscribeWebMCPToolsOptions extends GetWebMCPToolsOptions {\n /** Stops the subscription when it aborts. */\n signal: AbortSignal\n /** Receives a failure from the WebMCP `getTools()` call. */\n onError?: (error: unknown) => void\n}\n\nfunction isWebMCPToolReader(value: unknown): value is WebMCPToolReader {\n return (\n value !== null &&\n typeof value === 'object' &&\n 'getTools' in value &&\n typeof value.getTools === 'function' &&\n 'executeTool' in value &&\n typeof value.executeTool === 'function' &&\n 'addEventListener' in value &&\n typeof value.addEventListener === 'function' &&\n 'removeEventListener' in value &&\n typeof value.removeEventListener === 'function'\n )\n}\n\nfunction getWebMCPToolReader() {\n if (\n typeof document === 'undefined' ||\n (typeof isSecureContext !== 'undefined' && !isSecureContext) ||\n !('modelContext' in document) ||\n !isWebMCPToolReader(document.modelContext)\n ) {\n return undefined\n }\n return document.modelContext\n}\n\nfunction parseToolResult(result: string): unknown {\n try {\n return JSON.parse(result)\n } catch {\n return result\n }\n}\n\nasync function readWebMCPTools(\n reader: WebMCPToolReader,\n options: GetWebMCPToolsOptions | undefined,\n): Promise<Array<AnyClientTool>> {\n const pageTools = await reader.getTools()\n const names = new Set<string>()\n return pageTools\n .filter((tool) => options?.filter?.(tool) ?? true)\n .map((tool) => {\n if (names.has(tool.name)) {\n throw new Error(\n `Duplicate WebMCP tool name \"${tool.name}\". Use a filter or register tools with unique names.`,\n )\n }\n names.add(tool.name)\n return {\n __toolSide: 'client' as const,\n name: tool.name,\n description: tool.description,\n inputSchema: tool.inputSchema ?? { type: 'object' },\n async execute(input: unknown, context?: { abortSignal?: AbortSignal }) {\n const result = await reader.executeTool(\n tool,\n input,\n context?.abortSignal ? { signal: context.abortSignal } : {},\n )\n return parseToolResult(result)\n },\n }\n })\n}\n\n/**\n * Reads the WebMCP tools on the page and returns them as client tools.\n *\n * Pass the result to a chat as `tools`. Each tool runs through the WebMCP\n * `executeTool()` call. Unsupported browsers and server environments return\n * an empty array.\n *\n * @param options - A filter that skips tools.\n *\n * @example\n * ```ts\n * const tools = await getWebMCPTools({\n * filter: (tool) => tool.origin === location.origin,\n * })\n * ```\n */\nexport async function getWebMCPTools(\n options?: GetWebMCPToolsOptions,\n): Promise<Array<AnyClientTool>> {\n const reader = getWebMCPToolReader()\n return reader ? readWebMCPTools(reader, options) : []\n}\n\n/**\n * Calls `listener` with the page WebMCP tools now and after each\n * `toolchange` event, until `options.signal` aborts.\n *\n * Unsupported browsers and server environments call `listener` once with an\n * empty array. When a read fails, `options.onError` gets the error and the\n * listener keeps the last list.\n *\n * @param listener - Receives the current client tools.\n * @param options - The subscription signal, a filter, and an error callback.\n *\n * @example\n * ```ts\n * const controller = new AbortController()\n * subscribeWebMCPTools((tools) => client.updateOptions({ tools }), {\n * signal: controller.signal,\n * })\n * ```\n */\nexport function subscribeWebMCPTools(\n listener: (tools: Array<AnyClientTool>) => void,\n options: SubscribeWebMCPToolsOptions,\n): void {\n if (options.signal.aborted) return\n const reader = getWebMCPToolReader()\n if (!reader) {\n listener([])\n return\n }\n\n let latestRead = 0\n const refresh = () => {\n const read = ++latestRead\n const isCurrent = () => read === latestRead && !options.signal.aborted\n readWebMCPTools(reader, options).then(\n (tools) => {\n if (isCurrent()) listener(tools)\n },\n (error: unknown) => {\n if (isCurrent()) options.onError?.(error)\n },\n )\n }\n\n // Remove the listener by hand: Zone.js breaks the `signal` listener option.\n reader.addEventListener('toolchange', refresh)\n options.signal.addEventListener(\n 'abort',\n () => reader.removeEventListener('toolchange', refresh),\n { once: true },\n )\n refresh()\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanstack/ai-client",
3
- "version": "0.34.0",
3
+ "version": "0.35.1",
4
4
  "description": "Framework-agnostic headless client for TanStack AI chat, realtime sessions, streaming transports, and media generations.",
5
5
  "author": "Tanner Linsley",
6
6
  "license": "MIT",
@@ -57,8 +57,8 @@
57
57
  "src"
58
58
  ],
59
59
  "dependencies": {
60
- "@tanstack/ai": "^0.59.0",
61
- "@tanstack/ai-event-client": "^0.12.1",
60
+ "@tanstack/ai": "^0.61.0",
61
+ "@tanstack/ai-event-client": "^0.13.0",
62
62
  "@tanstack/ai-utils": "^0.4.1"
63
63
  },
64
64
  "devDependencies": {
@@ -330,6 +330,7 @@ const REJOIN_CONNECT_DEADLINE_MS = 2000
330
330
  const REJOIN_REBUILD_TRIGGERS = new Set<string>([
331
331
  'TEXT_MESSAGE_START',
332
332
  'TEXT_MESSAGE_CONTENT',
333
+ 'REASONING_MESSAGE_CONTENT',
333
334
  'TOOL_CALL_START',
334
335
  'MESSAGES_SNAPSHOT',
335
336
  // Drop the hydrated card before this chunk creates it again. A subagent
@@ -337,6 +338,20 @@ const REJOIN_REBUILD_TRIGGERS = new Set<string>([
337
338
  'SUBAGENT_STARTED',
338
339
  ])
339
340
 
341
+ function rebuildsAssistantMessage(chunk: StreamChunk): boolean {
342
+ if (chunk.type === 'REASONING_ENCRYPTED_VALUE') {
343
+ return (
344
+ chunk.subtype === 'message' &&
345
+ typeof chunk.encryptedValue === 'string' &&
346
+ chunk.encryptedValue.length > 0
347
+ )
348
+ }
349
+ if (chunk.type === 'STEP_FINISHED') {
350
+ return 'signature' in chunk && Boolean(chunk.signature)
351
+ }
352
+ return REJOIN_REBUILD_TRIGGERS.has(chunk.type)
353
+ }
354
+
340
355
  type SubagentCard = Extract<UIMessage['parts'][number], { type: 'subagent' }>
341
356
 
342
357
  /** Every subagent card in the messages, nested cards included. */
@@ -506,6 +521,8 @@ export class ChatClient<
506
521
  private readonly activeRunIds = new Set<string>()
507
522
  /** Latched by `dispose()`; stops any late async callback starting new work. */
508
523
  private disposed = false
524
+ /** The error a failed mount hydration set, cleared by the next successful one. */
525
+ private hydrationError: Error | undefined
509
526
  /** Whether a view is currently watching. See `attach` / `detach`. */
510
527
  private tailing = false
511
528
  /** Constructor inputs `attach()` needs on every re-attach, not just the first. */
@@ -1124,7 +1141,10 @@ export class ChatClient<
1124
1141
  const generation = this.historyGeneration
1125
1142
  try {
1126
1143
  result = await hydrate(this.threadId, hydrateOptions)
1127
- } catch {
1144
+ } catch (cause) {
1145
+ // Same staleness guard as the success path below: a failure from an
1146
+ // older attempt must not touch the state of a newer one.
1147
+ if (generation === this.historyGeneration) this.failHydration(cause)
1128
1148
  return
1129
1149
  }
1130
1150
  if (generation !== this.historyGeneration) return
@@ -1140,6 +1160,13 @@ export class ChatClient<
1140
1160
  if (this.disposed || !this.tailing) return
1141
1161
  // A send may have started while the fetch was in flight — don't stomp it.
1142
1162
  if (this.isLoading || this.abortController) return
1163
+ // A retry after a failed load succeeded: drop that failure, but not an
1164
+ // error something else set since.
1165
+ if (this.hydrationError && this.error === this.hydrationError) {
1166
+ this.setError(undefined)
1167
+ this.setStatus('ready')
1168
+ }
1169
+ this.hydrationError = undefined
1143
1170
  this.applyHydrationPage(result.page)
1144
1171
  if (result.messages.length > 0) {
1145
1172
  const windowMessages = normalizeMessagesDates(result.messages)
@@ -1170,6 +1197,36 @@ export class ChatClient<
1170
1197
  })()
1171
1198
  }
1172
1199
 
1200
+ /**
1201
+ * Surface a mount-hydration failure (`persistence: true`) on the observable
1202
+ * fields, mirroring `GenerationClient.failHydration`, so "this thread failed
1203
+ * to load" is distinguishable from "this thread has no messages" and the app
1204
+ * can show an error / offer a retry. A genuine miss — the server having no
1205
+ * record for a fresh thread — resolves normally and never reaches here; only a
1206
+ * thrown transport / authorize-gate error does.
1207
+ *
1208
+ * Skipped when the view unmounted (`!tailing`) or a `sendMessage` took
1209
+ * ownership while the hydrate GET was in flight, so a live run's state always
1210
+ * wins over a stale mount-time failure — same guard as the success path above.
1211
+ */
1212
+ private failHydration(cause: unknown): void {
1213
+ if (this.disposed || !this.tailing) return
1214
+ if (this.isLoading || this.abortController) return
1215
+ const error = cause instanceof Error ? cause : new Error(String(cause))
1216
+ // Mirror the send path: a BYOK key that is missing / locked must still
1217
+ // trigger the key-request flow on thread load, not just be reported.
1218
+ if (error instanceof ByokMissingError) {
1219
+ this.byok?.request(error.provider, 'missing')
1220
+ }
1221
+ if (error instanceof ByokBlockedError && error.reason === 'locked') {
1222
+ this.byok?.request(error.provider, 'locked')
1223
+ }
1224
+ this.hydrationError = error
1225
+ this.setStatus('error')
1226
+ this.setError(error)
1227
+ this.callbacksRef.current.onError(error)
1228
+ }
1229
+
1173
1230
  mountDevtools(): void {
1174
1231
  this.ensureThreadId()
1175
1232
  if (this.devtoolsMounted) {
@@ -1923,7 +1980,7 @@ export class ChatClient<
1923
1980
  attached = true
1924
1981
  clearTimeout(connectTimer)
1925
1982
  }
1926
- if (!rebuilt && REJOIN_REBUILD_TRIGGERS.has(chunk.type)) {
1983
+ if (!rebuilt && rebuildsAssistantMessage(chunk)) {
1927
1984
  rebuilt = true
1928
1985
  this.dropTrailingInFlightAssistant()
1929
1986
  }
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,
@@ -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
+ }