@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.
- package/dist/esm/chat-client.d.ts +15 -0
- package/dist/esm/chat-client.js +39 -2
- package/dist/esm/chat-client.js.map +1 -1
- package/dist/esm/index.d.ts +2 -2
- package/dist/esm/index.js +2 -2
- package/dist/esm/web-mcp-tools.d.ts +60 -0
- package/dist/esm/web-mcp-tools.js +92 -1
- package/dist/esm/web-mcp-tools.js.map +1 -1
- package/package.json +3 -3
- package/src/chat-client.ts +59 -2
- package/src/index.ts +8 -1
- package/src/web-mcp-tools.ts +181 -0
package/dist/esm/index.d.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
61
|
-
"@tanstack/ai-event-client": "^0.
|
|
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": {
|
package/src/chat-client.ts
CHANGED
|
@@ -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 &&
|
|
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 {
|
|
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,
|
package/src/web-mcp-tools.ts
CHANGED
|
@@ -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
|
+
}
|