diff --git a/.changeset/webmcp-tools.md b/.changeset/webmcp-tools.md new file mode 100644 index 0000000000..6500ed3c56 --- /dev/null +++ b/.changeset/webmcp-tools.md @@ -0,0 +1,13 @@ +--- +'@tanstack/ai-client': minor +'@tanstack/ai-react': minor +'@tanstack/ai-preact': minor +'@tanstack/ai-solid': minor +'@tanstack/ai-vue': minor +'@tanstack/ai-svelte': minor +'@tanstack/ai-angular': minor +'@tanstack/ai-octane': minor +'@tanstack/ai-remix': minor +--- + +Add the `registerWebMCPTools` registrar to `@tanstack/ai-client`. Each framework package adds a lifecycle wrapper through `useWebMCPTools`, `createWebMCPTools`, or `injectWebMCPTools`. diff --git a/docs/api/ai-angular.md b/docs/api/ai-angular.md index ead41921c2..c7c3cfa927 100644 --- a/docs/api/ai-angular.md +++ b/docs/api/ai-angular.md @@ -25,6 +25,37 @@ angular: @tanstack/ai-angular +## `injectWebMCPTools(tools, options?)` + +Register executable client tools for the current Angular injection owner. Angular removes them when it destroys that owner. + +For a complete setup and behavior guide, see [WebMCP Tools](../tools/webmcp). + +```typescript +import { Component } from "@angular/core"; +import { + injectWebMCPTools, + type InjectWebMCPToolsOptions, +} from "@tanstack/ai-angular"; +import { searchProducts } from "./tools"; + +const tools = [searchProducts]; +const options: InjectWebMCPToolsOptions = { + onError(error) { + console.error(error); + }, +}; + +@Component({ selector: "app-products", standalone: true, template: "" }) +export class ProductsComponent { + registration = injectWebMCPTools(tools, options); +} +``` + +`InjectWebMCPToolsOptions` contains `toolOptions`, `context`, and `onError`. The injectable owns the registration signal. + +The `context` field is required when a tool declares a required runtime context. Call this function only in an Angular injection context. + ## `injectChat(options?)` Main injectable for managing chat state in Angular with full type safety. diff --git a/docs/api/ai-client.md b/docs/api/ai-client.md index 03584ff366..47ca411e20 100644 --- a/docs/api/ai-client.md +++ b/docs/api/ai-client.md @@ -32,6 +32,50 @@ octane: @tanstack/ai-client +## `registerWebMCPTools(tools, options)` + +Expose executable client tools through the browser WebMCP API. Abort the required signal to remove all tools from this registration. + +For a complete setup and behavior guide, see [WebMCP Tools](../tools/webmcp). + +```typescript +import { + registerWebMCPTools, + type RegisterWebMCPToolsOptions, +} from "@tanstack/ai-client"; +import { searchProducts } from "./tools"; + +const controller = new AbortController(); +const tools = [searchProducts]; +const options: RegisterWebMCPToolsOptions = { + signal: controller.signal, + toolOptions: { + searchProducts: { + title: "Search products", + annotations: { readOnlyHint: true }, + }, + }, +}; + +await registerWebMCPTools(tools, options); + +// Remove these tools when their owner is no longer active. +controller.abort(); +``` + +The function returns `Promise`. It resolves without registration during server rendering, in an insecure context, or when WebMCP is unavailable. + +It validates Standard Schema inputs and outputs. A tool with `needsApproval: true` causes registration to fail. + +### Public option types + +- `RegisterWebMCPToolsOptions` - Requires `signal`. It also contains `toolOptions` and the client tool runtime `context`. +- `WebMCPToolOptionsByName` - A partial options map keyed by the inferred tool names. +- `WebMCPToolOptions` - Contains the optional `title` and `annotations` fields for one tool. +- `WebMCPToolAnnotations` - Contains the optional `readOnlyHint` and `untrustedContentHint` fields. + +`context` is required when a tool declares a required runtime context. If registration fails, the function removes tools that it registered during the call. + ## `ChatClient` The main client class for managing chat state. diff --git a/docs/api/ai-octane.md b/docs/api/ai-octane.md index 881b3138d0..d91db8cedd 100644 --- a/docs/api/ai-octane.md +++ b/docs/api/ai-octane.md @@ -24,6 +24,36 @@ octane: @tanstack/ai-octane octane `octane` is a required peer. This package publishes uncompiled source, like Svelte packages that ship `.svelte`. +## `useWebMCPTools(tools, options?)` + +Register executable client tools after the Octane component mounts. Octane removes them on cleanup and replaces them when `tools` or `options` change. + +For a complete setup and behavior guide, see [WebMCP Tools](../tools/webmcp). + +```tsx +import { + useWebMCPTools, + type UseWebMCPToolsOptions, +} from '@tanstack/ai-octane' +import { searchProducts } from './tools' + +const tools = [searchProducts] +const options: UseWebMCPToolsOptions = { + onError(error) { + console.error(error) + }, +} + +function ProductsPage() { + useWebMCPTools(tools, options) + return null +} +``` + +`UseWebMCPToolsOptions` contains `toolOptions`, `context`, and `onError`. The hook owns the registration signal. + +The `context` field is required when a tool declares a required runtime context. Keep `tools` and `options` stable when their values do not change. + ## `useChat(options)` Manages chat state in an Octane component. diff --git a/docs/api/ai-preact.md b/docs/api/ai-preact.md index 5c0c66d31e..c33146655b 100644 --- a/docs/api/ai-preact.md +++ b/docs/api/ai-preact.md @@ -22,6 +22,36 @@ preact: @tanstack/ai-preact +## `useWebMCPTools(tools, options?)` + +Register executable client tools after the Preact component mounts. Preact removes them on cleanup and replaces them when `tools` or `options` change. + +For a complete setup and behavior guide, see [WebMCP Tools](../tools/webmcp). + +```tsx +import { + useWebMCPTools, + type UseWebMCPToolsOptions, +} from "@tanstack/ai-preact"; +import { searchProducts } from "./tools"; + +const tools = [searchProducts]; +const options: UseWebMCPToolsOptions = { + onError(error) { + console.error(error); + }, +}; + +function ProductsPage() { + useWebMCPTools(tools, options); + return null; +} +``` + +`UseWebMCPToolsOptions` contains `toolOptions`, `context`, and `onError`. The hook owns the registration signal. + +The `context` field is required when a tool declares a required runtime context. Keep `tools` and `options` stable when their values do not change. + ## `useChat(options?)` Main hook for managing chat state in Preact with full type safety. diff --git a/docs/api/ai-react.md b/docs/api/ai-react.md index aac153828d..1e20b17310 100644 --- a/docs/api/ai-react.md +++ b/docs/api/ai-react.md @@ -30,6 +30,36 @@ react: @tanstack/ai-react +## `useWebMCPTools(tools, options?)` + +Register executable client tools after the React component mounts. React removes them on cleanup and replaces them when `tools` or `options` change. + +For a complete setup and behavior guide, see [WebMCP Tools](../tools/webmcp). + +```tsx +import { + useWebMCPTools, + type UseWebMCPToolsOptions, +} from "@tanstack/ai-react"; +import { searchProducts } from "./tools"; + +const tools = [searchProducts]; +const options: UseWebMCPToolsOptions = { + onError(error) { + console.error(error); + }, +}; + +function ProductsPage() { + useWebMCPTools(tools, options); + return null; +} +``` + +`UseWebMCPToolsOptions` contains `toolOptions`, `context`, and `onError`. The hook owns the registration signal. + +The `context` field is required when a tool declares a required runtime context. Keep `tools` and `options` stable when their values do not change. + ## `createChatHook(options)` Bind `chatOptions` once at module scope. Call `useChat()` in the screen to create the instance. Per-call overrides may set `threadId`, `initialMessages`, `live`, and `forwardedProps`. They must not change `tools`, `interrupts`, or `outputSchema`. diff --git a/docs/api/ai-remix.md b/docs/api/ai-remix.md index f8f6aa9d1a..e895adf75c 100644 --- a/docs/api/ai-remix.md +++ b/docs/api/ai-remix.md @@ -26,6 +26,40 @@ remix: @tanstack/ai-remix remix `remix` is a required peer. +## `createWebMCPTools(handle, tools, options?)` + +Register executable client tools for a Remix component. Remix removes them when the component `Handle` signal aborts. + +For a complete setup and behavior guide, see [WebMCP Tools](../tools/webmcp). + +```tsx +import { + createWebMCPTools, + type CreateWebMCPToolsOptions, +} from '@tanstack/ai-remix' +import { clientEntry, type Handle } from 'remix/ui' +import { searchProducts } from './tools' + +const tools = [searchProducts] +const options: CreateWebMCPToolsOptions = { + onError(error) { + console.error(error) + }, +} + +export const ProductsPage = clientEntry( + import.meta.url, + function ProductsPage(handle: Handle) { + createWebMCPTools(handle, tools, options) + return () => null + }, +) +``` + +`CreateWebMCPToolsOptions` contains `toolOptions`, `context`, and `onError`. The helper uses `handle.signal` for registration. + +The `context` field is required when a tool declares a required runtime context. + ## Server A Remix controller action can return the same SSE `Response` as any other host. diff --git a/docs/api/ai-solid.md b/docs/api/ai-solid.md index 63f4e41b56..aacf798bfb 100644 --- a/docs/api/ai-solid.md +++ b/docs/api/ai-solid.md @@ -25,6 +25,36 @@ solid: @tanstack/ai-solid +## `useWebMCPTools(tools, options?)` + +Register executable client tools for the current Solid owner. Solid removes them when the owner is cleaned up. + +For a complete setup and behavior guide, see [WebMCP Tools](../tools/webmcp). + +```tsx +import { + useWebMCPTools, + type UseWebMCPToolsOptions, +} from "@tanstack/ai-solid"; +import { searchProducts } from "./tools"; + +const tools = [searchProducts]; +const options: UseWebMCPToolsOptions = { + onError(error) { + console.error(error); + }, +}; + +function ProductsPage() { + useWebMCPTools(tools, options); + return null; +} +``` + +`UseWebMCPToolsOptions` contains `toolOptions`, `context`, and `onError`. The primitive owns the registration signal. + +The `context` field is required when a tool declares a required runtime context. + ## `createChatHook(options)` Bind `chatOptions` once at module scope. Call `useChat()` in the screen to create the instance. Per-call overrides may set `threadId`, `initialMessages`, `live`, and `forwardedProps`. They must not change `tools`, `interrupts`, or `outputSchema`. diff --git a/docs/api/ai-svelte.md b/docs/api/ai-svelte.md index 8094faf94e..a4cf9c93f8 100644 --- a/docs/api/ai-svelte.md +++ b/docs/api/ai-svelte.md @@ -25,6 +25,35 @@ svelte: @tanstack/ai-svelte +## `createWebMCPTools(tools, options?)` + +Register executable client tools during Svelte component initialization. Svelte removes them when the component is destroyed. + +For a complete setup and behavior guide, see [WebMCP Tools](../tools/webmcp). + +```svelte + +``` + +`CreateWebMCPToolsOptions` contains `toolOptions`, `context`, and `onError`. The factory owns the registration signal. + +The `context` field is required when a tool declares a required runtime context. + ## `createChatHook(options)` Bind `chatOptions` once at module scope. Call `createChat()` to create the instance. Per-call overrides may set `threadId`, `initialMessages`, `live`, and `forwardedProps`. They must not change `tools`, `interrupts`, or `outputSchema`. diff --git a/docs/api/ai-vue.md b/docs/api/ai-vue.md index f18df4328f..aee7c4ce85 100644 --- a/docs/api/ai-vue.md +++ b/docs/api/ai-vue.md @@ -25,6 +25,35 @@ vue: @tanstack/ai-vue +## `useWebMCPTools(tools, options?)` + +Register executable client tools for the current Vue scope. Vue removes them when the scope is disposed. + +For a complete setup and behavior guide, see [WebMCP Tools](../tools/webmcp). + +```vue + +``` + +`UseWebMCPToolsOptions` contains `toolOptions`, `context`, and `onError`. The composable owns the registration signal. + +The `context` field is required when a tool declares a required runtime context. + ## `createChatHook(options)` Bind `chatOptions` once at module scope. Call `useChat()` in the screen to create the instance. Per-call overrides may set `threadId`, `initialMessages`, `live`, and `forwardedProps`. They must not change `tools`, `interrupts`, or `outputSchema`. diff --git a/docs/config.json b/docs/config.json index d603561605..7858c9353a 100644 --- a/docs/config.json +++ b/docs/config.json @@ -159,7 +159,12 @@ "label": "Client Tools", "to": "tools/client-tools", "addedAt": "2026-04-15", - "updatedAt": "2026-08-20" + "updatedAt": "2026-09-02" + }, + { + "label": "WebMCP Tools", + "to": "tools/webmcp", + "addedAt": "2026-09-02" }, { "label": "Tool Approval Flow", @@ -385,7 +390,7 @@ "label": "MCP Server Tools", "to": "tools/mcp", "addedAt": "2026-06-05", - "updatedAt": "2026-07-31" + "updatedAt": "2026-09-02" }, { "label": "Managed MCP with chat()", @@ -1160,54 +1165,55 @@ "label": "@tanstack/ai-client", "to": "api/ai-client", "addedAt": "2026-04-15", - "updatedAt": "2026-08-24" + "updatedAt": "2026-09-02" }, { "label": "@tanstack/ai-react", "to": "api/ai-react", "addedAt": "2026-04-15", - "updatedAt": "2026-08-24" + "updatedAt": "2026-09-02" }, { "label": "@tanstack/ai-solid", "to": "api/ai-solid", "addedAt": "2026-04-15", - "updatedAt": "2026-08-24" + "updatedAt": "2026-09-02" }, { "label": "@tanstack/ai-preact", "to": "api/ai-preact", "addedAt": "2026-04-15", - "updatedAt": "2026-08-24" + "updatedAt": "2026-09-02" }, { "label": "@tanstack/ai-vue", "to": "api/ai-vue", "addedAt": "2026-04-15", - "updatedAt": "2026-08-24" + "updatedAt": "2026-09-02" }, { "label": "@tanstack/ai-svelte", "to": "api/ai-svelte", "addedAt": "2026-04-15", - "updatedAt": "2026-08-24" + "updatedAt": "2026-09-02" }, { "label": "@tanstack/ai-angular", "to": "api/ai-angular", "addedAt": "2026-06-15", - "updatedAt": "2026-08-24" + "updatedAt": "2026-09-02" }, { "label": "@tanstack/ai-octane", "to": "api/ai-octane", - "addedAt": "2026-08-21" + "addedAt": "2026-08-21", + "updatedAt": "2026-09-02" }, { "label": "@tanstack/ai-remix", "to": "api/ai-remix", "addedAt": "2026-09-01", - "updatedAt": "2026-09-01" + "updatedAt": "2026-09-02" } ] }, diff --git a/docs/tools/client-tools.md b/docs/tools/client-tools.md index ffcee5c2ee..55421aaa79 100644 --- a/docs/tools/client-tools.md +++ b/docs/tools/client-tools.md @@ -278,6 +278,16 @@ Client tools are **automatically executed** when the model calls them. The flow 4. Result is sent back to server 5. Conversation continues with the result +## Expose Client Tools Through WebMCP + +The normal client-tool path belongs to a TanStack chat run. It returns the browser result to the server and continues the conversation. + +WebMCP provides a separate execution path for browser agents. It calls the client handler and returns the result directly to WebMCP. + +WebMCP execution does not call `addToolResult`. It does not continue a TanStack chat run. + +Register the same executable client tools with a framework lifecycle wrapper or `registerWebMCPTools`. See [WebMCP Tools](./webmcp) for setup and security rules. + ## Client Runtime Context Client tools can receive typed runtime context as their second argument. This context is local to the `ChatClient` or framework hook instance and is not serialized to the server. diff --git a/docs/tools/mcp.md b/docs/tools/mcp.md index 7ddaf287d7..6c5041102f 100644 --- a/docs/tools/mcp.md +++ b/docs/tools/mcp.md @@ -19,6 +19,19 @@ keywords: > MCP tool execution is **server-side only**. The `createMCPClient` call lives in a server route (or serverless function) — never in browser code. +## Server MCP and WebMCP + +Server MCP and WebMCP solve different problems. + +| Integration | Where it runs | What it does | +|---|---|---| +| Server MCP | Your server route | Connects TanStack `chat()` to tools, resources, and prompts from an MCP server. | +| WebMCP | The browser page | Exposes executable client tools to a browser agent through `document.modelContext`. | + +WebMCP calls return directly to the browser agent. They do not become tool results in a TanStack chat run. + +See [WebMCP Tools](./webmcp) to expose browser actions. If your server needs MCP server tools, continue with this guide. + ## Installation diff --git a/docs/tools/webmcp.md b/docs/tools/webmcp.md new file mode 100644 index 0000000000..7f24a9ccbf --- /dev/null +++ b/docs/tools/webmcp.md @@ -0,0 +1,339 @@ +--- +title: WebMCP Tools +id: webmcp +order: 5 +description: "Expose TanStack AI client tools to browser agents through the experimental WebMCP API." +keywords: + - tanstack ai + - webmcp + - browser agents + - client tools + - registerWebMCPTools + - useWebMCPTools + - createWebMCPTools + - injectWebMCPTools +--- + +You have a browser action that a page agent needs to discover and execute. WebMCP exposes your TanStack client tools through `document.modelContext`. + +> **Experimental:** WebMCP support is experimental. Use it as progressive enhancement, not as a required application path. + +During SSR, in insecure contexts, or in unsupported browsers, registration resolves without adding tools. Your application continues to work without WebMCP. + +## 1. Install the packages + + + +react: @tanstack/ai @tanstack/ai-react zod +vue: @tanstack/ai @tanstack/ai-vue zod +solid: @tanstack/ai @tanstack/ai-solid zod +svelte: @tanstack/ai @tanstack/ai-svelte zod +preact: @tanstack/ai @tanstack/ai-preact zod +angular: @tanstack/ai @tanstack/ai-angular zod +vanilla: @tanstack/ai @tanstack/ai-client zod +octane: @tanstack/ai @tanstack/ai-octane octane zod +remix: @tanstack/ai @tanstack/ai-remix remix zod + + + +## 2. Define an executable client tool + +Define the tool at module scope. A browser agent executes the tool to open a help panel. + +```ts +// tools.ts +import { toolDefinition } from '@tanstack/ai' +import { z } from 'zod' + +export const openHelpPanel = toolDefinition({ + name: 'open_help_panel', + description: 'Open the help panel for a topic', + inputSchema: z.object({ + topic: z.string(), + }), + outputSchema: z.object({ + opened: z.boolean(), + }), +}).client((input) => { + const panel = document.querySelector('#help-panel') + if (!panel) { + return { opened: false } + } + + panel.dataset.topic = input.topic + panel.showPopover() + return { opened: true } +}) + +export const webMCPTools = [openHelpPanel] +``` + +WebMCP requires an executable `.client()` implementation. It also requires a nonempty description and a valid tool name. + +A tool name can contain 1 to 128 ASCII letters, numbers, underscores, hyphens, or periods. + +## 3. Register the tool + +Use the lifecycle wrapper for your framework. Each wrapper removes its registrations during owner cleanup. + + + +# React + +Call `useWebMCPTools` in a component. If its contents do not change, keep the tool array stable. + +```tsx +import { useWebMCPTools } from '@tanstack/ai-react' +import { webMCPTools } from './tools' + +const webMCPOptions = { + onError(error: unknown) { + console.error('WebMCP registration failed', error) + }, +} + +export function HelpPage() { + useWebMCPTools(webMCPTools, webMCPOptions) + + return ( +
+

Help

+
+ ) +} +``` + +# Vue + +Call `useWebMCPTools` in a setup scope. Vue removes the registrations during scope disposal. + +```vue + + + +``` + +# Solid + +Call `useWebMCPTools` in a reactive owner. Solid removes the registrations during owner cleanup. + +```tsx +import { useWebMCPTools } from '@tanstack/ai-solid' +import { webMCPTools } from './tools' + +export function HelpPage() { + useWebMCPTools(webMCPTools, { + onError: (error) => console.error('WebMCP registration failed', error), + }) + + return ( +
+

Help

+
+ ) +} +``` + +# Svelte + +Call `createWebMCPTools` in the component script. Svelte removes the registrations during component destruction. + +```svelte + + +
+

Help

+
+``` + +# Preact + +Call `useWebMCPTools` in a component. If its contents do not change, keep the tool array stable. + +```tsx +import { useWebMCPTools } from '@tanstack/ai-preact' +import { webMCPTools } from './tools' + +const webMCPOptions = { + onError(error: unknown) { + console.error('WebMCP registration failed', error) + }, +} + +export function HelpPage() { + useWebMCPTools(webMCPTools, webMCPOptions) + + return ( +
+

Help

+
+ ) +} +``` + +# Angular + +Call `injectWebMCPTools` in an injection context. Angular removes the registrations through `DestroyRef`. + +```ts ignore +import { Component } from '@angular/core' +import { injectWebMCPTools } from '@tanstack/ai-angular' +import { webMCPTools } from './tools' + +@Component({ + selector: 'app-help-page', + standalone: true, + template: '

Help

', +}) +export class HelpPage { + webMCP = injectWebMCPTools(webMCPTools, { + onError: (error) => console.error('WebMCP registration failed', error), + }) +} +``` + +# Octane + +Call `useWebMCPTools` in a component. If its contents do not change, keep the tool array stable. + +```tsx +import { useWebMCPTools } from '@tanstack/ai-octane' +import { webMCPTools } from './tools' + +const webMCPOptions = { + onError(error: unknown) { + console.error('WebMCP registration failed', error) + }, +} + +export function HelpPage() { + useWebMCPTools(webMCPTools, webMCPOptions) + + return ( +
+

Help

+
+ ) +} +``` + +# Remix + +Pass the component `Handle` to `createWebMCPTools`. Remix uses `handle.signal` for cleanup. + +```tsx +import { createWebMCPTools } from '@tanstack/ai-remix' +import { clientEntry, createElement } from 'remix/ui' +import { webMCPTools } from './tools' + +export const HelpPage = clientEntry( + import.meta.url, + function HelpPage(handle) { + createWebMCPTools(handle, webMCPTools, { + onError: (error) => console.error('WebMCP registration failed', error), + }) + + return () => + createElement( + 'section', + { id: 'help-panel', popover: 'auto' }, + createElement('h2', {}, 'Help'), + ) + }, +) +``` + + + +Changes to the `tools` or `options` value replace registrations in React, Preact, and Octane. Stable values prevent unnecessary registry changes. + +All wrappers accept `toolOptions`, `context`, and `onError`. A contextual client tool makes `context` and the options argument required. + +## Use the framework-neutral registrar + +If another lifecycle owns the registration, use `registerWebMCPTools`. The required signal controls how long the tools stay registered. + +```ts +import { registerWebMCPTools } from '@tanstack/ai-client' +import { webMCPTools } from './tools' + +const registration = new AbortController() + +await registerWebMCPTools(webMCPTools, { + signal: registration.signal, + toolOptions: { + open_help_panel: { + title: 'Open help', + annotations: { + readOnlyHint: false, + untrustedContentHint: false, + }, + }, + }, +}) + +export function disposeWebMCPTools() { + registration.abort() +} +``` + +Call `disposeWebMCPTools()` from the explicit teardown path for your application shell. Do not use `pagehide`, because BFCache can restore the page. + +The annotations are hints for the browser agent. They do not enforce permissions or approval. + +## Understand the execution boundary + +WebMCP calls the client handler directly. The result returns directly to WebMCP and does not continue a TanStack chat run. + +The execution context has these WebMCP rules: + +- `abortSignal` is the WebMCP execution signal. Pass it to cancellable browser APIs. +- `context` is the value from the registrar or wrapper options. +- `emitCustomEvent` does nothing because no TanStack stream exists. +- `toolCallId` is absent because WebMCP does not provide one. + +The registration signal and the execution signal have different jobs. The registration signal removes tools, while the execution signal cancels one call. + +Cancellation is cooperative. Your handler must observe `abortSignal` or pass it to an API that supports cancellation. + +## Validate data at the page boundary + +The registrar converts each input schema to JSON Schema for WebMCP discovery. + +For a Standard Schema, it validates input before execution. It also validates output before the result returns to WebMCP. + +A raw JSON Schema only describes the data. TanStack AI does not use it for runtime validation. + +If you use raw JSON Schema, validate untrusted input inside the handler. Return a value that matches the output schema. + +Tool results must be JSON-serializable. WebMCP reports a serialization failure to its caller. + +## Keep the security boundary on the server + +`needsApproval: true` tools cannot register with WebMCP. WebMCP annotations cannot enforce the TanStack approval contract. + +Do not expose a sensitive action only through a browser check. The server endpoint must enforce authentication, authorization, rate limits, and business rules. + +Tool names must be unique. Duplicate names in one call fail before registration changes the document registry. + +WebMCP also rejects a name that another owner already registered. If registration fails, the registrar removes only the tools added by that call. + +If WebMCP is available, your page now exposes `open_help_panel`. Other browsers continue to use the page without that agent action. diff --git a/packages/ai-angular/src/index.ts b/packages/ai-angular/src/index.ts index 0fc98d626c..e64df517ac 100644 --- a/packages/ai-angular/src/index.ts +++ b/packages/ai-angular/src/index.ts @@ -2,6 +2,10 @@ export { injectChat } from './inject-chat' export { injectByok } from './inject-byok' +// WebMCP Tools +export { injectWebMCPTools } from './inject-web-mcp-tools' +export type { InjectWebMCPToolsOptions } from './inject-web-mcp-tools' + // Generation export { injectGeneration } from './inject-generation' export type { diff --git a/packages/ai-angular/src/inject-web-mcp-tools.ts b/packages/ai-angular/src/inject-web-mcp-tools.ts new file mode 100644 index 0000000000..c2cd4d749c --- /dev/null +++ b/packages/ai-angular/src/inject-web-mcp-tools.ts @@ -0,0 +1,62 @@ +import { DestroyRef, assertInInjectionContext, inject } from '@angular/core' +import { registerWebMCPTools } from '@tanstack/ai-client' +import type { + AnyClientTool, + InferredClientContext, + RegisterWebMCPToolsOptions, +} from '@tanstack/ai-client' + +/** + * Options for {@link injectWebMCPTools}. + * + * Context is required when a client tool declares a required runtime context. + */ +export type InjectWebMCPToolsOptions< + TTools extends ReadonlyArray, + TContext = InferredClientContext, +> = Omit, 'signal'> & { + /** Receives asynchronous WebMCP registration errors. */ + onError?: (error: unknown) => void +} + +/** + * Registers executable client tools with WebMCP for the current Angular owner. + * + * Angular removes the registrations when it destroys the injection owner. Call + * this function in an injection context, such as a component field initializer. + * + * @param tools - The executable client tools to expose through WebMCP. + * @param options - Runtime context, per-tool options, and error handling. + * + * @example + * ```ts + * registration = injectWebMCPTools([statusTool], { + * onError: (error) => console.error(error), + * }) + * ``` + */ +export function injectWebMCPTools< + const TTools extends ReadonlyArray, + TContext = InferredClientContext, +>( + tools: TTools, + ...[options]: {} extends InjectWebMCPToolsOptions + ? [options?: InjectWebMCPToolsOptions] + : [options: InjectWebMCPToolsOptions] +) { + assertInInjectionContext(injectWebMCPTools) + const destroyRef = inject(DestroyRef) + const registrationController = new AbortController() + const { onError, ...registrationOptions } = options ?? {} + + destroyRef.onDestroy(() => registrationController.abort()) + + void registerWebMCPTools(tools, { + ...registrationOptions, + signal: registrationController.signal, + }).catch((error) => { + if (!registrationController.signal.aborted) { + onError?.(error) + } + }) +} diff --git a/packages/ai-angular/tests/inject-web-mcp-tools.test.ts b/packages/ai-angular/tests/inject-web-mcp-tools.test.ts new file mode 100644 index 0000000000..61c33aeb01 --- /dev/null +++ b/packages/ai-angular/tests/inject-web-mcp-tools.test.ts @@ -0,0 +1,165 @@ +import { Component } from '@angular/core' +import { TestBed } from '@angular/core/testing' +import { toolDefinition } from '@tanstack/ai/client' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { injectWebMCPTools, type InjectWebMCPToolsOptions } from '../src' + +interface RegisteredWebMCPTool { + name: string + title?: string + description: string + execute: (input: object, options: { signal: AbortSignal }) => Promise +} + +interface ModelContextOptions { + failOn?: string + pendingUntilAbort?: string +} + +function installModelContext({ + failOn, + pendingUntilAbort, +}: ModelContextOptions = {}) { + const tools = new Map() + const modelContext = { + tools, + async registerTool( + tool: RegisteredWebMCPTool, + options: { signal: AbortSignal }, + ) { + if (tool.name === failOn) { + throw new Error(`${tool.name} registration failed`) + } + if (tool.name === pendingUntilAbort) { + await new Promise((_resolve, reject) => { + options.signal.addEventListener( + 'abort', + () => reject(new Error(`${tool.name} registration aborted`)), + { once: true }, + ) + }) + } + + tools.set(tool.name, tool) + options.signal.addEventListener('abort', () => tools.delete(tool.name), { + once: true, + }) + }, + } + + Object.defineProperty(document, 'modelContext', { + configurable: true, + value: modelContext, + }) + vi.stubGlobal('isSecureContext', true) + return modelContext +} + +function createInjectionOwner(register: () => void) { + @Component({ standalone: true, template: '' }) + class Host { + registration = register() + } + + const fixture = TestBed.createComponent(Host) + fixture.detectChanges() + return fixture +} + +const statusTool = toolDefinition({ + name: 'status', + description: 'Read the current status', +}).client(async () => ({ status: 'ready' })) + +afterEach(() => { + TestBed.resetTestingModule() + Reflect.deleteProperty(document, 'modelContext') + vi.unstubAllGlobals() +}) + +describe('injectWebMCPTools', () => { + it('registers client tools with their inferred options', async () => { + const modelContext = installModelContext() + createInjectionOwner(() => + injectWebMCPTools([statusTool], { + toolOptions: { status: { title: 'Current status' } }, + }), + ) + + await vi.waitFor(() => expect(modelContext.tools.has('status')).toBe(true)) + expect(modelContext.tools.get('status')).toMatchObject({ + name: 'status', + title: 'Current status', + description: 'Read the current status', + }) + }) + + it('removes registered tools when the injection owner is destroyed', async () => { + const modelContext = installModelContext() + const fixture = createInjectionOwner(() => injectWebMCPTools([statusTool])) + await vi.waitFor(() => expect(modelContext.tools.has('status')).toBe(true)) + + fixture.destroy() + + expect(modelContext.tools.size).toBe(0) + }) + + it('reports asynchronous registration errors', async () => { + installModelContext({ failOn: 'status' }) + const onError = vi.fn() + createInjectionOwner(() => injectWebMCPTools([statusTool], { onError })) + + await vi.waitFor(() => expect(onError).toHaveBeenCalledOnce()) + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ message: 'status registration failed' }), + ) + }) + + it('does not report a pending registration error after destruction', async () => { + installModelContext({ pendingUntilAbort: 'status' }) + const onError = vi.fn() + const fixture = createInjectionOwner(() => + injectWebMCPTools([statusTool], { onError }), + ) + + fixture.destroy() + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(onError).not.toHaveBeenCalled() + }) + + it('preserves inferred tool options and required runtime context', () => { + const contextualTool = toolDefinition({ + name: 'tenant_status', + description: 'Read tenant status', + }).client<{ tenantId: string }>((_input, context) => { + return context.context.tenantId + }) + + function _scenario() { + const options: InjectWebMCPToolsOptions< + readonly [typeof contextualTool] + > = { + context: { tenantId: 'tenant-1' }, + toolOptions: { tenant_status: { title: 'Tenant status' } }, + } + injectWebMCPTools([contextualTool], options) + + // @ts-expect-error contextual tools require runtime context + injectWebMCPTools([contextualTool]) + injectWebMCPTools([contextualTool], { + context: { tenantId: 'tenant-1' }, + toolOptions: { + // @ts-expect-error tool options only accept inferred tool names + unknown_tool: {}, + }, + }) + } + + void _scenario + }) + + it('throws outside an injection context', () => { + expect(() => injectWebMCPTools([statusTool])).toThrow() + }) +}) diff --git a/packages/ai-client/src/index.ts b/packages/ai-client/src/index.ts index db4c11d2b1..a53e8bc742 100644 --- a/packages/ai-client/src/index.ts +++ b/packages/ai-client/src/index.ts @@ -14,6 +14,13 @@ export type { } from './interrupt-manager' export { createMcpAppBridge } from './mcp-app-bridge' export type { McpAppBridge, CreateMcpAppBridgeOptions } from './mcp-app-bridge' +export { registerWebMCPTools } from './web-mcp-tools' +export type { + RegisterWebMCPToolsOptions, + WebMCPToolAnnotations, + WebMCPToolOptions, + WebMCPToolOptionsByName, +} from './web-mcp-tools' export { RealtimeClient } from './realtime-client' export { GenerationClient } from './generation-client' export { VideoGenerationClient } from './video-generation-client' diff --git a/packages/ai-client/src/web-mcp-tools.ts b/packages/ai-client/src/web-mcp-tools.ts new file mode 100644 index 0000000000..06c3ab1356 --- /dev/null +++ b/packages/ai-client/src/web-mcp-tools.ts @@ -0,0 +1,208 @@ +import { + convertSchemaToJsonSchema, + validateWithStandardSchema, +} from '@tanstack/ai/client' +import type { AnyClientTool } from '@tanstack/ai/client' +import type { + ClientContextOptionFromTools, + InferredClientContext, +} from './types' + +interface WebMCPTool { + name: string + title?: string + description: string + inputSchema?: object + annotations?: WebMCPToolAnnotations + execute: (input: object, options: { signal: AbortSignal }) => Promise +} + +interface WebMCPModelContext { + registerTool: ( + tool: WebMCPTool, + options: { signal: AbortSignal }, + ) => Promise +} + +/** WebMCP behavior hints for one registered tool. */ +export interface WebMCPToolAnnotations { + /** Indicates that the tool does not modify state. */ + readOnlyHint?: boolean + /** Indicates that the tool can return content that the application does not trust. */ + untrustedContentHint?: boolean +} + +/** Display and behavior options for one WebMCP tool. */ +export interface WebMCPToolOptions { + /** A human-readable title for browser user interfaces. */ + title?: string + /** Optional behavior hints for browser agents. */ + annotations?: WebMCPToolAnnotations +} + +/** WebMCP options keyed by the inferred names in a client tool list. */ +export type WebMCPToolOptionsByName< + TTools extends ReadonlyArray, +> = Partial<{ + [TName in TTools[number]['name']]: WebMCPToolOptions +}> + +/** + * Options for {@link registerWebMCPTools}. + * + * The signal controls the registration lifetime. Context is required when a + * client tool declares a required runtime context. + */ +export type RegisterWebMCPToolsOptions< + TTools extends ReadonlyArray, + TContext = InferredClientContext, +> = { + /** Removes all tools from this call when the signal aborts. */ + signal: AbortSignal + /** Per-tool display and behavior options. */ + toolOptions?: WebMCPToolOptionsByName +} & ClientContextOptionFromTools + +function isWebMCPModelContext(value: unknown): value is WebMCPModelContext { + return ( + value !== null && + typeof value === 'object' && + 'registerTool' in value && + typeof value.registerTool === 'function' + ) +} + +function getToolOptions( + toolOptions: Partial> | undefined, + name: TName, +) { + return toolOptions?.[name] +} + +async function validateSchemaValue(schema: unknown, value: unknown) { + const result = await validateWithStandardSchema(schema, value) + if (result.success) { + return result.data + } + + throw new Error( + `Validation failed: ${result.issues.map((issue) => issue.message).join(', ')}`, + ) +} + +/** + * Registers executable TanStack client tools with the browser WebMCP API. + * + * Unsupported browsers and server environments resolve without registration. + * Abort `options.signal` to remove every tool registered by this call. + * + * @param tools - The executable client tools to expose through WebMCP. + * @param options - The registration signal, runtime context, and per-tool options. + * + * @example + * ```ts + * const controller = new AbortController() + * await registerWebMCPTools(tools, { signal: controller.signal }) + * controller.abort() + * ``` + */ +export async function registerWebMCPTools< + const TTools extends ReadonlyArray, + TContext = InferredClientContext, +>(tools: TTools, options: RegisterWebMCPToolsOptions) { + if ( + typeof document === 'undefined' || + (typeof isSecureContext !== 'undefined' && !isSecureContext) || + !('modelContext' in document) || + !isWebMCPModelContext(document.modelContext) + ) { + return + } + if (tools.length === 0) { + return + } + + const names = new Set() + const webMCPTools = tools.map((tool) => { + if (!/^[A-Za-z0-9_.-]{1,128}$/.test(tool.name)) { + throw new Error( + `WebMCP tool name "${tool.name}" must contain 1 to 128 ASCII letters, numbers, underscores, hyphens, or periods.`, + ) + } + if (names.has(tool.name)) { + throw new Error(`Duplicate WebMCP tool name "${tool.name}".`) + } + if (tool.description.trim() === '') { + throw new Error(`WebMCP tool "${tool.name}" must have a description.`) + } + if (typeof tool.execute !== 'function') { + throw new Error( + `WebMCP tool "${tool.name}" must have an execute handler.`, + ) + } + if (tool.needsApproval === true) { + throw new Error( + `WebMCP tool "${tool.name}" cannot use needsApproval: true.`, + ) + } + + names.add(tool.name) + const toolOptions = getToolOptions(options.toolOptions, tool.name) + const inputSchema = tool.inputSchema + const outputSchema = tool.outputSchema + const convertedInputSchema = convertSchemaToJsonSchema(inputSchema) + const inputSchemaType = convertedInputSchema?.type + const requiresNonObjectInput = + (typeof inputSchemaType === 'string' && inputSchemaType !== 'object') || + (Array.isArray(inputSchemaType) && !inputSchemaType.includes('object')) + if (requiresNonObjectInput) { + throw new Error( + `WebMCP tool "${tool.name}" input schema must accept an object.`, + ) + } + const execute = tool.execute + + return { + name: tool.name, + description: tool.description, + ...(toolOptions?.title !== undefined ? { title: toolOptions.title } : {}), + ...(convertedInputSchema !== undefined + ? { inputSchema: convertedInputSchema } + : {}), + ...(toolOptions?.annotations !== undefined + ? { annotations: toolOptions.annotations } + : {}), + async execute(input: object, executionOptions: { signal: AbortSignal }) { + const validatedInput = await validateSchemaValue(inputSchema, input) + const output = await execute(validatedInput, { + abortSignal: executionOptions.signal, + context: options.context, + emitCustomEvent() {}, + }) + return validateSchemaValue(outputSchema, output) + }, + } + }) + + const registrationController = new AbortController() + const abortRegistration = () => + registrationController.abort(options.signal.reason) + + if (options.signal.aborted) { + abortRegistration() + } else { + options.signal.addEventListener('abort', abortRegistration, { once: true }) + } + + try { + for (const tool of webMCPTools) { + await document.modelContext.registerTool(tool, { + signal: registrationController.signal, + }) + } + } catch (error) { + registrationController.abort(error) + options.signal.removeEventListener('abort', abortRegistration) + throw error + } +} diff --git a/packages/ai-client/tests/web-mcp-tools-types.test.ts b/packages/ai-client/tests/web-mcp-tools-types.test.ts new file mode 100644 index 0000000000..2210ce01ff --- /dev/null +++ b/packages/ai-client/tests/web-mcp-tools-types.test.ts @@ -0,0 +1,88 @@ +import { describe, expectTypeOf, it } from 'vitest' +import { toolDefinition } from '@tanstack/ai/client' +import { + registerWebMCPTools, + type RegisterWebMCPToolsOptions, + type WebMCPToolAnnotations, + type WebMCPToolOptions, +} from '../src' + +const controller = new AbortController() +const search = toolDefinition({ + name: 'search_products', + description: 'Search products', +}).client(async () => []) +const addToCart = toolDefinition({ + name: 'add_to_cart', + description: 'Add a product to the cart', +}).client(async () => ({ ok: true })) +const tools = [search, addToCart] as const + +describe('registerWebMCPTools types', () => { + it('requires a lifecycle signal', () => { + // @ts-expect-error signal is required + registerWebMCPTools(tools, {}) + }) + + it('keys per-tool options by inferred tool names', () => { + const options: RegisterWebMCPToolsOptions = { + signal: controller.signal, + toolOptions: { + search_products: { + title: 'Search products', + annotations: { + readOnlyHint: true, + untrustedContentHint: true, + }, + }, + add_to_cart: { title: 'Add to cart' }, + }, + } + + expectTypeOf(options.toolOptions?.search_products).toEqualTypeOf< + WebMCPToolOptions | undefined + >() + expectTypeOf( + options.toolOptions?.search_products?.annotations, + ).toEqualTypeOf() + + registerWebMCPTools(tools, { + signal: controller.signal, + toolOptions: { + // @ts-expect-error tool options only accept names from the tool list + unknown_tool: {}, + }, + }) + }) + + it('requires the runtime context inferred from contextual tools', () => { + const contextual = toolDefinition({ + name: 'contextual', + description: 'Read tenant context', + }).client<{ tenantId: string }>((_input, executionContext) => { + return executionContext.context.tenantId + }) + + // @ts-expect-error contextual tools require context + registerWebMCPTools([contextual], { signal: controller.signal }) + + registerWebMCPTools([contextual], { + signal: controller.signal, + context: { tenantId: 'tenant-1' }, + }) + + registerWebMCPTools([contextual], { + signal: controller.signal, + // @ts-expect-error context must satisfy every contextual tool + context: { accountId: 'account-1' }, + }) + }) + + it('does not expose cross-origin registration options', () => { + registerWebMCPTools(tools, { + signal: controller.signal, + // @ts-expect-error cross-origin exposure is not supported + exposedTo: ['https://agent.example'], + }) + }) +}) diff --git a/packages/ai-client/tests/web-mcp-tools.test.ts b/packages/ai-client/tests/web-mcp-tools.test.ts new file mode 100644 index 0000000000..abf6867b42 --- /dev/null +++ b/packages/ai-client/tests/web-mcp-tools.test.ts @@ -0,0 +1,456 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { toolDefinition } from '@tanstack/ai/client' +import { z } from 'zod' +import { registerWebMCPTools } from '../src/web-mcp-tools' + +interface RegisteredWebMCPTool { + name: string + title?: string + description: string + inputSchema?: unknown + annotations?: { + readOnlyHint?: boolean + untrustedContentHint?: boolean + } + execute: (input: object, options: { signal: AbortSignal }) => Promise +} + +interface ModelContextOptions { + failOn?: string +} + +function installModelContext(options: ModelContextOptions = {}) { + const tools = new Map() + const modelContext = { + tools, + async registerTool( + tool: RegisteredWebMCPTool, + registrationOptions: { signal: AbortSignal }, + ) { + if (registrationOptions.signal.aborted) { + throw registrationOptions.signal.reason + } + if (tool.name === options.failOn) { + throw new Error(`${tool.name} registration failed`) + } + if (tools.has(tool.name)) { + throw new Error(`Tool ${tool.name} is already registered`) + } + + tools.set(tool.name, tool) + registrationOptions.signal.addEventListener( + 'abort', + () => tools.delete(tool.name), + { once: true }, + ) + }, + } + + vi.stubGlobal('document', { modelContext }) + return modelContext +} + +function getRegisteredTool( + modelContext: ReturnType, + name: string, +) { + const tool = modelContext.tools.get(name) + if (!tool) { + throw new Error(`Tool ${name} was not registered`) + } + return tool +} + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('registerWebMCPTools', () => { + it('registers, executes, and removes an executable client tool', async () => { + const modelContext = installModelContext() + const lifecycle = new AbortController() + const execution = new AbortController() + const emittedEvents: Array = [] + const echoDefinition = toolDefinition({ + name: 'echo', + description: 'Echo a value', + inputSchema: z.object({ value: z.string() }), + outputSchema: z.object({ + value: z.string(), + tenantId: z.string(), + aborted: z.boolean(), + hasToolCallId: z.boolean(), + }), + }) + const tool = echoDefinition.client<{ tenantId: string }>( + async (input, toolContext) => { + toolContext.emitCustomEvent('ignored', { value: input.value }) + emittedEvents.push('handler ran') + return { + value: input.value, + tenantId: toolContext.context.tenantId, + aborted: toolContext.abortSignal === execution.signal, + hasToolCallId: Object.hasOwn(toolContext, 'toolCallId'), + } + }, + ) + + await registerWebMCPTools([tool], { + signal: lifecycle.signal, + context: { tenantId: 'tenant-1' }, + toolOptions: { + echo: { + title: 'Echo value', + annotations: { + readOnlyHint: true, + untrustedContentHint: true, + }, + }, + }, + }) + + const registered = getRegisteredTool(modelContext, 'echo') + expect(registered).toMatchObject({ + name: 'echo', + title: 'Echo value', + description: 'Echo a value', + annotations: { + readOnlyHint: true, + untrustedContentHint: true, + }, + }) + expect(registered.inputSchema).toEqual({ + type: 'object', + properties: { value: { type: 'string' } }, + required: ['value'], + }) + await expect( + registered.execute({ value: 'hello' }, { signal: execution.signal }), + ).resolves.toEqual({ + value: 'hello', + tenantId: 'tenant-1', + aborted: true, + hasToolCallId: false, + }) + expect(emittedEvents).toEqual(['handler ran']) + + lifecycle.abort() + expect(modelContext.tools.size).toBe(0) + }) + + it('validates Standard Schema input before execution', async () => { + const modelContext = installModelContext() + const lifecycle = new AbortController() + const execute = vi.fn(async () => ({ ok: true })) + const tool = toolDefinition({ + name: 'validate_input', + description: 'Validate input', + inputSchema: z.object({ value: z.string() }), + }).client(execute) + + await registerWebMCPTools([tool], { signal: lifecycle.signal }) + const registered = getRegisteredTool(modelContext, 'validate_input') + + await expect( + registered.execute( + { value: 1 }, + { signal: new AbortController().signal }, + ), + ).rejects.toThrow('Validation failed') + expect(execute).not.toHaveBeenCalled() + }) + + it('validates Standard Schema output after execution', async () => { + const modelContext = installModelContext() + const lifecycle = new AbortController() + const outputSchema = z + .unknown() + .refine( + (value) => + value !== null && + typeof value === 'object' && + 'ok' in value && + typeof value.ok === 'boolean', + 'Expected a boolean ok value', + ) + const tool = toolDefinition({ + name: 'validate_output', + description: 'Validate output', + outputSchema, + }).client(async () => ({ ok: 'no' })) + + await registerWebMCPTools([tool], { signal: lifecycle.signal }) + const registered = getRegisteredTool(modelContext, 'validate_output') + + await expect( + registered.execute({}, { signal: new AbortController().signal }), + ).rejects.toThrow('Validation failed') + }) + + it('passes transformed Standard Schema values through execution', async () => { + const modelContext = installModelContext() + const lifecycle = new AbortController() + const receivedInputs: Array<{ value: string }> = [] + const tool = toolDefinition({ + name: 'transform_values', + description: 'Transform values', + inputSchema: z + .object({ value: z.string() }) + .transform(({ value }) => ({ value: value.trim() })), + outputSchema: z + .object({ value: z.string() }) + .transform(({ value }) => ({ result: value.toUpperCase() })), + }).client(async (input) => { + receivedInputs.push(input) + return { value: `${input.value}!` } + }) + + await registerWebMCPTools([tool], { signal: lifecycle.signal }) + const registered = getRegisteredTool(modelContext, 'transform_values') + + await expect( + registered.execute( + { value: ' hello ' }, + { signal: new AbortController().signal }, + ), + ).resolves.toEqual({ result: 'HELLO!' }) + expect(receivedInputs).toEqual([{ value: 'hello' }]) + }) + + it('uses the schemas captured during registration', async () => { + const modelContext = installModelContext() + const lifecycle = new AbortController() + const tool = toolDefinition({ + name: 'captured_schemas', + description: 'Use captured schemas', + inputSchema: z.object({ value: z.string() }), + outputSchema: z.object({ value: z.string() }), + }).client(async (input) => input) + + await registerWebMCPTools([tool], { signal: lifecycle.signal }) + tool.inputSchema = z + .object({ value: z.string() }) + .refine(() => false, 'Mutated input schema') + tool.outputSchema = z + .object({ value: z.string() }) + .refine(() => false, 'Mutated output schema') + const registered = getRegisteredTool(modelContext, 'captured_schemas') + + await expect( + registered.execute( + { value: 'original' }, + { signal: new AbortController().signal }, + ), + ).resolves.toEqual({ value: 'original' }) + }) + + it('leaves raw JSON Schema validation to the client handler', async () => { + const modelContext = installModelContext() + const lifecycle = new AbortController() + const rawSchema = { + type: ['object', 'null'], + properties: { value: { type: 'string' } }, + required: ['value'], + } + const tool = toolDefinition({ + name: 'raw_schema', + description: 'Use a raw schema', + inputSchema: rawSchema, + }).client(async (input) => input) + + await registerWebMCPTools([tool], { signal: lifecycle.signal }) + const registered = getRegisteredTool(modelContext, 'raw_schema') + + await expect( + registered.execute( + { value: 1 }, + { signal: new AbortController().signal }, + ), + ).resolves.toEqual({ value: 1 }) + }) + + it('rejects a scalar top-level input schema', async () => { + const modelContext = installModelContext() + const lifecycle = new AbortController() + const tool = toolDefinition({ + name: 'scalar_input', + description: 'Use scalar input', + inputSchema: z.string(), + }).client(async (input) => input) + + await expect( + registerWebMCPTools([tool], { signal: lifecycle.signal }), + ).rejects.toThrow('input schema must accept an object') + expect(modelContext.tools.size).toBe(0) + }) + + it('resolves without registration when WebMCP is unavailable', async () => { + const lifecycle = new AbortController() + const tool = toolDefinition({ + name: 'unsupported', + description: 'Do not register', + }).client(async () => ({ ok: true })) + + vi.stubGlobal('document', {}) + await expect( + registerWebMCPTools([tool], { signal: lifecycle.signal }), + ).resolves.toBeUndefined() + + vi.stubGlobal('document', { modelContext: {} }) + await expect( + registerWebMCPTools([tool], { signal: lifecycle.signal }), + ).resolves.toBeUndefined() + + const modelContext = installModelContext() + vi.stubGlobal('isSecureContext', false) + await expect( + registerWebMCPTools([tool], { signal: lifecycle.signal }), + ).resolves.toBeUndefined() + expect(modelContext.tools.size).toBe(0) + }) + + it('does not register tools for an already-aborted lifetime', async () => { + const modelContext = installModelContext() + const lifecycle = new AbortController() + const abortReason = new Error('registration stopped') + const tool = toolDefinition({ + name: 'already_aborted', + description: 'Do not register after abort', + }).client(async () => ({ ok: true })) + lifecycle.abort(abortReason) + + await expect( + registerWebMCPTools([tool], { signal: lifecycle.signal }), + ).rejects.toBe(abortReason) + expect(modelContext.tools.size).toBe(0) + }) + + it('does not link the lifecycle signal for an empty tool list', async () => { + installModelContext() + const lifecycle = new AbortController() + const addEventListener = vi.spyOn(lifecycle.signal, 'addEventListener') + + await registerWebMCPTools([], { signal: lifecycle.signal }) + + expect(addEventListener).not.toHaveBeenCalled() + }) + + it('removes partial registrations when registration fails', async () => { + const modelContext = installModelContext({ failOn: 'second' }) + const lifecycle = new AbortController() + const first = toolDefinition({ + name: 'first', + description: 'First tool', + }).client(async () => 'first') + const second = toolDefinition({ + name: 'second', + description: 'Second tool', + }).client(async () => 'second') + + await expect( + registerWebMCPTools([first, second], { signal: lifecycle.signal }), + ).rejects.toThrow('second registration failed') + expect(modelContext.tools.size).toBe(0) + }) + + it.each([ + { + label: 'a missing execute handler', + tools: [ + toolDefinition({ + name: 'missing_execute', + description: 'Missing execute', + }).client(), + ], + error: 'execute', + }, + { + label: 'an approval tool', + tools: [ + toolDefinition({ + name: 'approval', + description: 'Approval tool', + needsApproval: true, + }).client(async () => 'approved'), + ], + error: 'needsApproval', + }, + { + label: 'an invalid name', + tools: [ + toolDefinition({ + name: 'invalid name', + description: 'Invalid name', + }).client(async () => 'invalid'), + ], + error: 'WebMCP tool name', + }, + { + label: 'a name longer than 128 characters', + tools: [ + toolDefinition({ + name: 'a'.repeat(129), + description: 'Long name', + }).client(async () => 'invalid'), + ], + error: 'WebMCP tool name', + }, + { + label: 'an empty description', + tools: [ + toolDefinition({ name: 'empty_description', description: '' }).client( + async () => 'invalid', + ), + ], + error: 'description', + }, + ])('rejects $label before registration', async ({ tools, error }) => { + const modelContext = installModelContext() + const lifecycle = new AbortController() + + await expect( + registerWebMCPTools(tools, { signal: lifecycle.signal }), + ).rejects.toThrow(error) + expect(modelContext.tools.size).toBe(0) + }) + + it('rejects duplicate names before registration', async () => { + const modelContext = installModelContext() + const lifecycle = new AbortController() + const first = toolDefinition({ + name: 'duplicate', + description: 'First duplicate', + }).client(async () => 'first') + const duplicate = toolDefinition({ + name: 'duplicate', + description: 'Second duplicate', + }).client(async () => 'second') + + await expect( + registerWebMCPTools([first, duplicate], { signal: lifecycle.signal }), + ).rejects.toThrow('Duplicate WebMCP tool name') + expect(modelContext.tools.size).toBe(0) + }) + + it('preserves a foreign registration with the same name', async () => { + const modelContext = installModelContext() + const lifecycle = new AbortController() + const foreignTool: RegisteredWebMCPTool = { + name: 'foreign_owned', + description: 'Foreign tool', + async execute(input) { + return input + }, + } + modelContext.tools.set(foreignTool.name, foreignTool) + const tool = toolDefinition({ + name: 'foreign_owned', + description: 'Conflicting tool', + }).client(async () => 'local') + + await expect( + registerWebMCPTools([tool], { signal: lifecycle.signal }), + ).rejects.toThrow('already registered') + expect(modelContext.tools.get(foreignTool.name)).toBe(foreignTool) + }) +}) diff --git a/packages/ai-octane/src/index.ts b/packages/ai-octane/src/index.ts index 2ee2d32734..8797a194e0 100644 --- a/packages/ai-octane/src/index.ts +++ b/packages/ai-octane/src/index.ts @@ -3,6 +3,8 @@ export { createChatHook } from './create-chat-hook' export { useRealtimeChat } from './use-realtime-chat.tsrx' export { useMcpAppBridge } from './use-mcp-app-bridge.tsrx' export type { UseMcpAppBridgeOptions } from './use-mcp-app-bridge.tsrx' +export { useWebMCPTools } from './use-web-mcp-tools.tsrx' +export type { UseWebMCPToolsOptions } from './use-web-mcp-tools.tsrx' export type { DeepPartial, UseChatOptions, diff --git a/packages/ai-octane/src/use-web-mcp-tools.tsrx b/packages/ai-octane/src/use-web-mcp-tools.tsrx new file mode 100644 index 0000000000..60b1352cdc --- /dev/null +++ b/packages/ai-octane/src/use-web-mcp-tools.tsrx @@ -0,0 +1,61 @@ +import { useEffect } from 'octane' +import { registerWebMCPTools } from '@tanstack/ai-client' +import type { + AnyClientTool, + InferredClientContext, + RegisterWebMCPToolsOptions, +} from '@tanstack/ai-client' + +/** Options for the Octane {@link useWebMCPTools} lifecycle hook. */ +export type UseWebMCPToolsOptions< + TTools extends ReadonlyArray, + TContext = InferredClientContext, +> = Omit, 'signal'> & { + /** Receives an asynchronous registration failure. */ + onError?: (error: unknown) => void +} + +type UseWebMCPToolsArguments< + TTools extends ReadonlyArray, + TContext, +> = RegisterWebMCPToolsOptions extends { context: unknown } + ? [options: UseWebMCPToolsOptions] + : [options?: UseWebMCPToolsOptions] + +/** + * Registers client tools with WebMCP for the lifetime of an Octane component. + * + * The hook replaces the registration when `tools` or `options` changes. + * Unsupported browsers and server rendering do not register tools. + * + * @param tools - The executable client tools to expose through WebMCP. + * @param options - Runtime context, per-tool options, and an error callback. + * + * @example + * ```tsx + * useWebMCPTools([searchProducts], { + * toolOptions: { searchProducts: { title: 'Search products' } }, + * }) + * ``` + */ +export function useWebMCPTools< + const TTools extends ReadonlyArray, + TContext = InferredClientContext, +>( + tools: TTools, + ...[options]: UseWebMCPToolsArguments +) { + useEffect(() => { + const controller = new AbortController() + const { onError, ...registrationOptions } = options ?? {} + + registerWebMCPTools(tools, { + ...registrationOptions, + signal: controller.signal, + }).catch((error) => { + if (!controller.signal.aborted) onError?.(error) + }) + + return () => controller.abort() + }, [tools, options]) +} diff --git a/packages/ai-octane/src/use-web-mcp-tools.tsrx.d.ts b/packages/ai-octane/src/use-web-mcp-tools.tsrx.d.ts new file mode 100644 index 0000000000..2303e9e7cb --- /dev/null +++ b/packages/ai-octane/src/use-web-mcp-tools.tsrx.d.ts @@ -0,0 +1,42 @@ +// Declaration companion generated from use-web-mcp-tools.tsrx. +import type { + AnyClientTool, + InferredClientContext, + RegisterWebMCPToolsOptions, +} from '@tanstack/ai-client' + +/** Options for the Octane {@link useWebMCPTools} lifecycle hook. */ +export type UseWebMCPToolsOptions< + TTools extends ReadonlyArray, + TContext = InferredClientContext, +> = Omit, 'signal'> & { + /** Receives an asynchronous registration failure. */ + onError?: (error: unknown) => void +} +type UseWebMCPToolsArguments< + TTools extends ReadonlyArray, + TContext, +> = + RegisterWebMCPToolsOptions extends { context: unknown } + ? [options: UseWebMCPToolsOptions] + : [options?: UseWebMCPToolsOptions] +/** + * Registers client tools with WebMCP for the lifetime of an Octane component. + * + * The hook replaces the registration when `tools` or `options` changes. + * Unsupported browsers and server rendering do not register tools. + * + * @param tools - The executable client tools to expose through WebMCP. + * @param options - Runtime context, per-tool options, and an error callback. + * + * @example + * ```tsx + * useWebMCPTools([searchProducts], { + * toolOptions: { searchProducts: { title: 'Search products' } }, + * }) + * ``` + */ +export declare function useWebMCPTools< + const TTools extends ReadonlyArray, + TContext = InferredClientContext, +>(tools: TTools, ...[options]: UseWebMCPToolsArguments): void diff --git a/packages/ai-octane/tests/conformance/exports.test.ts b/packages/ai-octane/tests/conformance/exports.test.ts index e6a0bc6b6d..da89982909 100644 --- a/packages/ai-octane/tests/conformance/exports.test.ts +++ b/packages/ai-octane/tests/conformance/exports.test.ts @@ -3,6 +3,7 @@ import { useChat, useRealtimeChat, useMcpAppBridge, + useWebMCPTools, useGeneration, useGenerateImage, useGenerateAudio, @@ -19,6 +20,7 @@ describe('package exports', () => { useChat, useRealtimeChat, useMcpAppBridge, + useWebMCPTools, useGeneration, useGenerateImage, useGenerateAudio, diff --git a/packages/ai-octane/tests/conformance/use-web-mcp-tools.test.ts b/packages/ai-octane/tests/conformance/use-web-mcp-tools.test.ts new file mode 100644 index 0000000000..2e6550f65b --- /dev/null +++ b/packages/ai-octane/tests/conformance/use-web-mcp-tools.test.ts @@ -0,0 +1,197 @@ +// @vitest-environment jsdom +import { renderHook, waitFor } from '@octanejs/testing-library' +import { toolDefinition } from '@tanstack/ai/client' +import { afterEach, describe, expect, expectTypeOf, it, vi } from 'vitest' +import { useWebMCPTools } from '../../src/use-web-mcp-tools.tsrx' +import type { AnyClientTool } from '@tanstack/ai/client' +import type { UseWebMCPToolsOptions } from '../../src/use-web-mcp-tools.tsrx' + +interface RegisteredWebMCPTool { + name: string + title?: string +} + +interface ModelContextOptions { + failOn?: string + pending?: boolean +} + +function installModelContext({ failOn, pending }: ModelContextOptions = {}) { + const tools = new Map() + const modelContext = { + tools, + async registerTool( + tool: RegisteredWebMCPTool, + options: { signal: AbortSignal }, + ) { + if (tool.name === failOn) { + throw new Error(`${tool.name} registration failed`) + } + + tools.set(tool.name, tool) + if (pending) { + await new Promise((_resolve, reject) => { + options.signal.addEventListener( + 'abort', + () => { + tools.delete(tool.name) + reject(new Error(`${tool.name} registration aborted`)) + }, + { once: true }, + ) + }) + return + } + + options.signal.addEventListener('abort', () => tools.delete(tool.name), { + once: true, + }) + }, + } + + Object.defineProperty(document, 'modelContext', { + configurable: true, + value: modelContext, + }) + return modelContext +} + +const statusTool = toolDefinition({ + name: 'status', + description: 'Read the status', +}).client(async () => ({ ok: true })) +const firstTool = toolDefinition({ + name: 'first', + description: 'Run the first tool', +}).client(async () => 'first') +const secondTool = toolDefinition({ + name: 'second', + description: 'Run the second tool', +}).client(async () => 'second') + +afterEach(() => { + Reflect.deleteProperty(document, 'modelContext') +}) + +describe('useWebMCPTools', () => { + it('removes registered tools on unmount', async () => { + const modelContext = installModelContext() + const tools = [statusTool] as const + const { unmount } = renderHook(() => useWebMCPTools(tools)) + + await waitFor(() => expect(modelContext.tools.has('status')).toBe(true)) + unmount() + + expect(modelContext.tools.size).toBe(0) + }) + + it('replaces registered tools when the list changes', async () => { + const modelContext = installModelContext() + const initialTools: ReadonlyArray = [firstTool] + const nextTools: ReadonlyArray = [secondTool] + const { rerender, unmount } = renderHook( + (tools: ReadonlyArray) => useWebMCPTools(tools), + { initialProps: initialTools }, + ) + + await waitFor(() => + expect([...modelContext.tools.keys()]).toEqual(['first']), + ) + rerender(nextTools) + await waitFor(() => + expect([...modelContext.tools.keys()]).toEqual(['second']), + ) + unmount() + }) + + it('replaces registered tools when options change', async () => { + const modelContext = installModelContext() + const tools = [statusTool] as const + const initialOptions: UseWebMCPToolsOptions = { + toolOptions: { status: { title: 'Initial status' } }, + } + const nextOptions: UseWebMCPToolsOptions = { + toolOptions: { status: { title: 'Current status' } }, + } + const { rerender, unmount } = renderHook( + (options: UseWebMCPToolsOptions) => + useWebMCPTools(tools, options), + { initialProps: initialOptions }, + ) + + await waitFor(() => + expect(modelContext.tools.get('status')?.title).toBe('Initial status'), + ) + rerender(nextOptions) + await waitFor(() => + expect(modelContext.tools.get('status')?.title).toBe('Current status'), + ) + unmount() + }) + + it('reports asynchronous registration failures', async () => { + installModelContext({ failOn: 'status' }) + const onError = vi.fn<(error: unknown) => void>() + const tools = [statusTool] as const + const { unmount } = renderHook(() => useWebMCPTools(tools, { onError })) + + await waitFor(() => expect(onError).toHaveBeenCalledOnce()) + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ message: 'status registration failed' }), + ) + unmount() + }) + + it('does not report aborted pending registrations', async () => { + const modelContext = installModelContext({ pending: true }) + const onError = vi.fn<(error: unknown) => void>() + const initialTools: ReadonlyArray = [firstTool] + const nextTools: ReadonlyArray = [secondTool] + const { rerender, unmount } = renderHook( + (tools: ReadonlyArray) => + useWebMCPTools(tools, { onError }), + { initialProps: initialTools }, + ) + + await waitFor(() => expect(modelContext.tools.has('first')).toBe(true)) + rerender(nextTools) + await waitFor(() => expect(modelContext.tools.has('second')).toBe(true)) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(onError).not.toHaveBeenCalled() + + unmount() + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(modelContext.tools.size).toBe(0) + expect(onError).not.toHaveBeenCalled() + }) + + it('preserves inferred tool options and required context', () => { + const tools = [statusTool, firstTool] as const + const options: UseWebMCPToolsOptions = { + toolOptions: { status: { title: 'Status' } }, + } + expectTypeOf(options.toolOptions?.status?.title).toEqualTypeOf< + string | undefined + >() + + const contextualTool = toolDefinition({ + name: 'contextual', + description: 'Read tenant context', + }).client<{ tenantId: string }>( + (_input, context) => context.context.tenantId, + ) + const contextualTools = [contextualTool] as const + const checkCalls = () => { + useWebMCPTools(tools, { + toolOptions: { + // @ts-expect-error options only accept names from the tool list + unknown: {}, + }, + }) + // @ts-expect-error contextual tools require context + useWebMCPTools(contextualTools) + useWebMCPTools(contextualTools, { context: { tenantId: 'tenant-1' } }) + } + void checkCalls + }) +}) diff --git a/packages/ai-preact/src/index.ts b/packages/ai-preact/src/index.ts index 81a1a2602d..85422b0c96 100644 --- a/packages/ai-preact/src/index.ts +++ b/packages/ai-preact/src/index.ts @@ -3,6 +3,8 @@ export { createChatHook } from './create-chat-hook' export { useByok } from './use-byok' export { useMcpAppBridge } from './use-mcp-app-bridge' export type { UseMcpAppBridgeOptions } from './use-mcp-app-bridge' +export { useWebMCPTools } from './use-web-mcp-tools' +export type { UseWebMCPToolsOptions } from './use-web-mcp-tools' export type { UseChatOptions, UseChatReturn, diff --git a/packages/ai-preact/src/use-web-mcp-tools.ts b/packages/ai-preact/src/use-web-mcp-tools.ts new file mode 100644 index 0000000000..564cce1e4d --- /dev/null +++ b/packages/ai-preact/src/use-web-mcp-tools.ts @@ -0,0 +1,59 @@ +import { useEffect } from 'preact/hooks' +import { registerWebMCPTools } from '@tanstack/ai-client' +import type { + AnyClientTool, + InferredClientContext, + RegisterWebMCPToolsOptions, +} from '@tanstack/ai-client' + +/** Options for the Preact {@link useWebMCPTools} lifecycle hook. */ +export type UseWebMCPToolsOptions< + TTools extends ReadonlyArray, + TContext = InferredClientContext, +> = Omit, 'signal'> & { + /** Receives an asynchronous registration failure. */ + onError?: (error: unknown) => void +} + +type UseWebMCPToolsArguments< + TTools extends ReadonlyArray, + TContext, +> = + RegisterWebMCPToolsOptions extends { context: unknown } + ? [options: UseWebMCPToolsOptions] + : [options?: UseWebMCPToolsOptions] + +/** + * Registers client tools with WebMCP for the lifetime of a Preact component. + * + * The hook replaces the registration when `tools` or `options` changes. + * Unsupported browsers and server rendering do not register tools. + * + * @param tools - The executable client tools to expose through WebMCP. + * @param options - Runtime context, per-tool options, and an error callback. + * + * @example + * ```tsx + * useWebMCPTools([searchProducts], { + * toolOptions: { searchProducts: { title: 'Search products' } }, + * }) + * ``` + */ +export function useWebMCPTools< + const TTools extends ReadonlyArray, + TContext = InferredClientContext, +>(tools: TTools, ...[options]: UseWebMCPToolsArguments) { + useEffect(() => { + const controller = new AbortController() + const { onError, ...registrationOptions } = options ?? {} + + registerWebMCPTools(tools, { + ...registrationOptions, + signal: controller.signal, + }).catch((error) => { + if (!controller.signal.aborted) onError?.(error) + }) + + return () => controller.abort() + }, [tools, options]) +} diff --git a/packages/ai-preact/tests/use-web-mcp-tools.test.ts b/packages/ai-preact/tests/use-web-mcp-tools.test.ts new file mode 100644 index 0000000000..f349516520 --- /dev/null +++ b/packages/ai-preact/tests/use-web-mcp-tools.test.ts @@ -0,0 +1,197 @@ +// @vitest-environment jsdom +import { renderHook, waitFor } from '@testing-library/preact' +import { toolDefinition } from '@tanstack/ai/client' +import { afterEach, describe, expect, expectTypeOf, it, vi } from 'vitest' +import { useWebMCPTools } from '../src' +import type { AnyClientTool } from '@tanstack/ai/client' +import type { UseWebMCPToolsOptions } from '../src' + +interface RegisteredWebMCPTool { + name: string + title?: string +} + +interface ModelContextOptions { + failOn?: string + pending?: boolean +} + +function installModelContext({ failOn, pending }: ModelContextOptions = {}) { + const tools = new Map() + const modelContext = { + tools, + async registerTool( + tool: RegisteredWebMCPTool, + options: { signal: AbortSignal }, + ) { + if (tool.name === failOn) { + throw new Error(`${tool.name} registration failed`) + } + + tools.set(tool.name, tool) + if (pending) { + await new Promise((_resolve, reject) => { + options.signal.addEventListener( + 'abort', + () => { + tools.delete(tool.name) + reject(new Error(`${tool.name} registration aborted`)) + }, + { once: true }, + ) + }) + return + } + + options.signal.addEventListener('abort', () => tools.delete(tool.name), { + once: true, + }) + }, + } + + Object.defineProperty(document, 'modelContext', { + configurable: true, + value: modelContext, + }) + return modelContext +} + +const statusTool = toolDefinition({ + name: 'status', + description: 'Read the status', +}).client(async () => ({ ok: true })) +const firstTool = toolDefinition({ + name: 'first', + description: 'Run the first tool', +}).client(async () => 'first') +const secondTool = toolDefinition({ + name: 'second', + description: 'Run the second tool', +}).client(async () => 'second') + +afterEach(() => { + Reflect.deleteProperty(document, 'modelContext') +}) + +describe('useWebMCPTools', () => { + it('removes registered tools on unmount', async () => { + const modelContext = installModelContext() + const tools = [statusTool] as const + const { unmount } = renderHook(() => useWebMCPTools(tools)) + + await waitFor(() => expect(modelContext.tools.has('status')).toBe(true)) + unmount() + + expect(modelContext.tools.size).toBe(0) + }) + + it('replaces registered tools when the list changes', async () => { + const modelContext = installModelContext() + const initialTools: ReadonlyArray = [firstTool] + const nextTools: ReadonlyArray = [secondTool] + const { rerender, unmount } = renderHook( + (tools: ReadonlyArray) => useWebMCPTools(tools), + { initialProps: initialTools }, + ) + + await waitFor(() => + expect([...modelContext.tools.keys()]).toEqual(['first']), + ) + rerender(nextTools) + await waitFor(() => + expect([...modelContext.tools.keys()]).toEqual(['second']), + ) + unmount() + }) + + it('replaces registered tools when options change', async () => { + const modelContext = installModelContext() + const tools = [statusTool] as const + const initialOptions: UseWebMCPToolsOptions = { + toolOptions: { status: { title: 'Initial status' } }, + } + const nextOptions: UseWebMCPToolsOptions = { + toolOptions: { status: { title: 'Current status' } }, + } + const { rerender, unmount } = renderHook( + (options: UseWebMCPToolsOptions) => + useWebMCPTools(tools, options), + { initialProps: initialOptions }, + ) + + await waitFor(() => + expect(modelContext.tools.get('status')?.title).toBe('Initial status'), + ) + rerender(nextOptions) + await waitFor(() => + expect(modelContext.tools.get('status')?.title).toBe('Current status'), + ) + unmount() + }) + + it('reports asynchronous registration failures', async () => { + installModelContext({ failOn: 'status' }) + const onError = vi.fn<(error: unknown) => void>() + const tools = [statusTool] as const + const { unmount } = renderHook(() => useWebMCPTools(tools, { onError })) + + await waitFor(() => expect(onError).toHaveBeenCalledOnce()) + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ message: 'status registration failed' }), + ) + unmount() + }) + + it('does not report aborted pending registrations', async () => { + const modelContext = installModelContext({ pending: true }) + const onError = vi.fn<(error: unknown) => void>() + const initialTools: ReadonlyArray = [firstTool] + const nextTools: ReadonlyArray = [secondTool] + const { rerender, unmount } = renderHook( + (tools: ReadonlyArray) => + useWebMCPTools(tools, { onError }), + { initialProps: initialTools }, + ) + + await waitFor(() => expect(modelContext.tools.has('first')).toBe(true)) + rerender(nextTools) + await waitFor(() => expect(modelContext.tools.has('second')).toBe(true)) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(onError).not.toHaveBeenCalled() + + unmount() + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(modelContext.tools.size).toBe(0) + expect(onError).not.toHaveBeenCalled() + }) + + it('preserves inferred tool options and required context', () => { + const tools = [statusTool, firstTool] as const + const options: UseWebMCPToolsOptions = { + toolOptions: { status: { title: 'Status' } }, + } + expectTypeOf(options.toolOptions?.status?.title).toEqualTypeOf< + string | undefined + >() + + const contextualTool = toolDefinition({ + name: 'contextual', + description: 'Read tenant context', + }).client<{ tenantId: string }>( + (_input, context) => context.context.tenantId, + ) + const contextualTools = [contextualTool] as const + const checkCalls = () => { + useWebMCPTools(tools, { + toolOptions: { + // @ts-expect-error options only accept names from the tool list + unknown: {}, + }, + }) + // @ts-expect-error contextual tools require context + useWebMCPTools(contextualTools) + useWebMCPTools(contextualTools, { context: { tenantId: 'tenant-1' } }) + } + void checkCalls + }) +}) diff --git a/packages/ai-react/src/index.ts b/packages/ai-react/src/index.ts index 1ac2e347ab..a9c1b55476 100644 --- a/packages/ai-react/src/index.ts +++ b/packages/ai-react/src/index.ts @@ -4,6 +4,8 @@ export { useByok } from './use-byok' export { useRealtimeChat } from './use-realtime-chat' export { useMcpAppBridge } from './use-mcp-app-bridge' export type { UseMcpAppBridgeOptions } from './use-mcp-app-bridge' +export { useWebMCPTools } from './use-web-mcp-tools' +export type { UseWebMCPToolsOptions } from './use-web-mcp-tools' export type { DeepPartial, UseChatOptions, diff --git a/packages/ai-react/src/use-web-mcp-tools.ts b/packages/ai-react/src/use-web-mcp-tools.ts new file mode 100644 index 0000000000..2c394783a1 --- /dev/null +++ b/packages/ai-react/src/use-web-mcp-tools.ts @@ -0,0 +1,59 @@ +import { useEffect } from 'react' +import { registerWebMCPTools } from '@tanstack/ai-client' +import type { + AnyClientTool, + InferredClientContext, + RegisterWebMCPToolsOptions, +} from '@tanstack/ai-client' + +/** Options for the React {@link useWebMCPTools} lifecycle hook. */ +export type UseWebMCPToolsOptions< + TTools extends ReadonlyArray, + TContext = InferredClientContext, +> = Omit, 'signal'> & { + /** Receives an asynchronous registration failure. */ + onError?: (error: unknown) => void +} + +type UseWebMCPToolsArguments< + TTools extends ReadonlyArray, + TContext, +> = + RegisterWebMCPToolsOptions extends { context: unknown } + ? [options: UseWebMCPToolsOptions] + : [options?: UseWebMCPToolsOptions] + +/** + * Registers client tools with WebMCP for the lifetime of a React component. + * + * The hook replaces the registration when `tools` or `options` changes. + * Unsupported browsers and server rendering do not register tools. + * + * @param tools - The executable client tools to expose through WebMCP. + * @param options - Runtime context, per-tool options, and an error callback. + * + * @example + * ```tsx + * useWebMCPTools([searchProducts], { + * toolOptions: { searchProducts: { title: 'Search products' } }, + * }) + * ``` + */ +export function useWebMCPTools< + const TTools extends ReadonlyArray, + TContext = InferredClientContext, +>(tools: TTools, ...[options]: UseWebMCPToolsArguments) { + useEffect(() => { + const controller = new AbortController() + const { onError, ...registrationOptions } = options ?? {} + + registerWebMCPTools(tools, { + ...registrationOptions, + signal: controller.signal, + }).catch((error) => { + if (!controller.signal.aborted) onError?.(error) + }) + + return () => controller.abort() + }, [tools, options]) +} diff --git a/packages/ai-react/tests/use-web-mcp-tools.test.ts b/packages/ai-react/tests/use-web-mcp-tools.test.ts new file mode 100644 index 0000000000..2f12157074 --- /dev/null +++ b/packages/ai-react/tests/use-web-mcp-tools.test.ts @@ -0,0 +1,197 @@ +// @vitest-environment jsdom +import { renderHook, waitFor } from '@testing-library/react' +import { toolDefinition } from '@tanstack/ai/client' +import { afterEach, describe, expect, expectTypeOf, it, vi } from 'vitest' +import { useWebMCPTools } from '../src' +import type { AnyClientTool } from '@tanstack/ai/client' +import type { UseWebMCPToolsOptions } from '../src' + +interface RegisteredWebMCPTool { + name: string + title?: string +} + +interface ModelContextOptions { + failOn?: string + pending?: boolean +} + +function installModelContext({ failOn, pending }: ModelContextOptions = {}) { + const tools = new Map() + const modelContext = { + tools, + async registerTool( + tool: RegisteredWebMCPTool, + options: { signal: AbortSignal }, + ) { + if (tool.name === failOn) { + throw new Error(`${tool.name} registration failed`) + } + + tools.set(tool.name, tool) + if (pending) { + await new Promise((_resolve, reject) => { + options.signal.addEventListener( + 'abort', + () => { + tools.delete(tool.name) + reject(new Error(`${tool.name} registration aborted`)) + }, + { once: true }, + ) + }) + return + } + + options.signal.addEventListener('abort', () => tools.delete(tool.name), { + once: true, + }) + }, + } + + Object.defineProperty(document, 'modelContext', { + configurable: true, + value: modelContext, + }) + return modelContext +} + +const statusTool = toolDefinition({ + name: 'status', + description: 'Read the status', +}).client(async () => ({ ok: true })) +const firstTool = toolDefinition({ + name: 'first', + description: 'Run the first tool', +}).client(async () => 'first') +const secondTool = toolDefinition({ + name: 'second', + description: 'Run the second tool', +}).client(async () => 'second') + +afterEach(() => { + Reflect.deleteProperty(document, 'modelContext') +}) + +describe('useWebMCPTools', () => { + it('removes registered tools on unmount', async () => { + const modelContext = installModelContext() + const tools = [statusTool] as const + const { unmount } = renderHook(() => useWebMCPTools(tools)) + + await waitFor(() => expect(modelContext.tools.has('status')).toBe(true)) + unmount() + + expect(modelContext.tools.size).toBe(0) + }) + + it('replaces registered tools when the list changes', async () => { + const modelContext = installModelContext() + const initialTools: ReadonlyArray = [firstTool] + const nextTools: ReadonlyArray = [secondTool] + const { rerender, unmount } = renderHook( + (tools: ReadonlyArray) => useWebMCPTools(tools), + { initialProps: initialTools }, + ) + + await waitFor(() => + expect([...modelContext.tools.keys()]).toEqual(['first']), + ) + rerender(nextTools) + await waitFor(() => + expect([...modelContext.tools.keys()]).toEqual(['second']), + ) + unmount() + }) + + it('replaces registered tools when options change', async () => { + const modelContext = installModelContext() + const tools = [statusTool] as const + const initialOptions: UseWebMCPToolsOptions = { + toolOptions: { status: { title: 'Initial status' } }, + } + const nextOptions: UseWebMCPToolsOptions = { + toolOptions: { status: { title: 'Current status' } }, + } + const { rerender, unmount } = renderHook( + (options: UseWebMCPToolsOptions) => + useWebMCPTools(tools, options), + { initialProps: initialOptions }, + ) + + await waitFor(() => + expect(modelContext.tools.get('status')?.title).toBe('Initial status'), + ) + rerender(nextOptions) + await waitFor(() => + expect(modelContext.tools.get('status')?.title).toBe('Current status'), + ) + unmount() + }) + + it('reports asynchronous registration failures', async () => { + installModelContext({ failOn: 'status' }) + const onError = vi.fn<(error: unknown) => void>() + const tools = [statusTool] as const + const { unmount } = renderHook(() => useWebMCPTools(tools, { onError })) + + await waitFor(() => expect(onError).toHaveBeenCalledOnce()) + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ message: 'status registration failed' }), + ) + unmount() + }) + + it('does not report aborted pending registrations', async () => { + const modelContext = installModelContext({ pending: true }) + const onError = vi.fn<(error: unknown) => void>() + const initialTools: ReadonlyArray = [firstTool] + const nextTools: ReadonlyArray = [secondTool] + const { rerender, unmount } = renderHook( + (tools: ReadonlyArray) => + useWebMCPTools(tools, { onError }), + { initialProps: initialTools }, + ) + + await waitFor(() => expect(modelContext.tools.has('first')).toBe(true)) + rerender(nextTools) + await waitFor(() => expect(modelContext.tools.has('second')).toBe(true)) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(onError).not.toHaveBeenCalled() + + unmount() + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(modelContext.tools.size).toBe(0) + expect(onError).not.toHaveBeenCalled() + }) + + it('preserves inferred tool options and required context', () => { + const tools = [statusTool, firstTool] as const + const options: UseWebMCPToolsOptions = { + toolOptions: { status: { title: 'Status' } }, + } + expectTypeOf(options.toolOptions?.status?.title).toEqualTypeOf< + string | undefined + >() + + const contextualTool = toolDefinition({ + name: 'contextual', + description: 'Read tenant context', + }).client<{ tenantId: string }>( + (_input, context) => context.context.tenantId, + ) + const contextualTools = [contextualTool] as const + const checkCalls = () => { + useWebMCPTools(tools, { + toolOptions: { + // @ts-expect-error options only accept names from the tool list + unknown: {}, + }, + }) + // @ts-expect-error contextual tools require context + useWebMCPTools(contextualTools) + useWebMCPTools(contextualTools, { context: { tenantId: 'tenant-1' } }) + } + void checkCalls + }) +}) diff --git a/packages/ai-remix/src/create-web-mcp-tools.ts b/packages/ai-remix/src/create-web-mcp-tools.ts new file mode 100644 index 0000000000..1e51dd97f6 --- /dev/null +++ b/packages/ai-remix/src/create-web-mcp-tools.ts @@ -0,0 +1,59 @@ +import { registerWebMCPTools } from '@tanstack/ai-client' +import type { + AnyClientTool, + InferredClientContext, + RegisterWebMCPToolsOptions, +} from '@tanstack/ai-client' +import type { Handle } from 'remix/ui' + +/** Options for {@link createWebMCPTools}. */ +export type CreateWebMCPToolsOptions< + TTools extends ReadonlyArray, + TContext = InferredClientContext, +> = Omit, 'signal'> & { + /** Receives an asynchronous registration error. */ + onError?: (error: unknown) => void +} + +type CreateWebMCPToolsArguments< + TTools extends ReadonlyArray, + TContext, +> = + {} extends CreateWebMCPToolsOptions + ? [options?: CreateWebMCPToolsOptions] + : [options: CreateWebMCPToolsOptions] + +/** + * Registers executable client tools with WebMCP for a Remix component. + * + * The component Handle signal removes every tool registered by this call. + * + * @param handle - The Remix component Handle from setup. + * @param tools - The executable client tools to expose through WebMCP. + * @param options - Runtime context, per-tool options, and error handling. + * + * @example + * ```tsx + * function Products(handle: Handle) { + * createWebMCPTools(handle, [searchProducts]) + * return () => + * } + * ``` + */ +export function createWebMCPTools< + const TTools extends ReadonlyArray, + TContext = InferredClientContext, +>( + handle: Pick, + tools: TTools, + ...[options]: CreateWebMCPToolsArguments +) { + void registerWebMCPTools(tools, { + ...options, + signal: handle.signal, + }).catch((error) => { + if (!handle.signal.aborted) { + options?.onError?.(error) + } + }) +} diff --git a/packages/ai-remix/src/index.ts b/packages/ai-remix/src/index.ts index 3c668cddec..4d932278b9 100644 --- a/packages/ai-remix/src/index.ts +++ b/packages/ai-remix/src/index.ts @@ -3,6 +3,8 @@ export { createByok } from './create-byok.ts' export { createRealtimeChat } from './create-realtime-chat.ts' export { createMcpAppBridge } from './create-mcp-app-bridge.ts' export type { CreateMcpAppBridgeOptions } from './create-mcp-app-bridge.ts' +export { createWebMCPTools } from './create-web-mcp-tools.ts' +export type { CreateWebMCPToolsOptions } from './create-web-mcp-tools.ts' export type { DeepPartial, CreateChatOptions, diff --git a/packages/ai-remix/tests/create-web-mcp-tools.test.ts b/packages/ai-remix/tests/create-web-mcp-tools.test.ts new file mode 100644 index 0000000000..05dadb01d4 --- /dev/null +++ b/packages/ai-remix/tests/create-web-mcp-tools.test.ts @@ -0,0 +1,140 @@ +import { toolDefinition } from '@tanstack/ai/client' +import type { Handle } from 'remix/ui' +import { afterEach, describe, expect, expectTypeOf, it, vi } from 'vitest' +import { createWebMCPTools } from '../src/index' +import type { CreateWebMCPToolsOptions } from '../src/index' + +interface RegisteredWebMCPTool { + name: string +} + +function installModelContext({ + failOn, + keepRegistrationPending = false, +}: { + failOn?: string + keepRegistrationPending?: boolean +} = {}) { + const tools = new Map() + const modelContext = { + tools, + registerTool: vi.fn( + async (tool: RegisteredWebMCPTool, options: { signal: AbortSignal }) => { + if (tool.name === failOn) { + throw new Error(`${tool.name} registration failed`) + } + if (keepRegistrationPending) { + await new Promise((_resolve, reject) => { + options.signal.addEventListener( + 'abort', + () => reject(options.signal.reason), + { once: true }, + ) + }) + } + + tools.set(tool.name, tool) + options.signal.addEventListener( + 'abort', + () => tools.delete(tool.name), + { + once: true, + }, + ) + }, + ), + } + + Object.defineProperty(document, 'modelContext', { + configurable: true, + value: modelContext, + }) + return modelContext +} + +function createFakeHandle() { + const controller = new AbortController() + const handle: Pick = { signal: controller.signal } + return { handle, abort: () => controller.abort() } +} + +const statusTool = toolDefinition({ + name: 'status', + description: 'Get the current status', +}).client(async () => ({ ok: true })) + +afterEach(() => { + Reflect.deleteProperty(document, 'modelContext') +}) + +describe('createWebMCPTools (Remix)', () => { + it('uses the component Handle signal for cleanup', async () => { + const modelContext = installModelContext() + const { handle, abort } = createFakeHandle() + + createWebMCPTools(handle, [statusTool]) + await vi.waitFor(() => expect(modelContext.tools.has('status')).toBe(true)) + + abort() + expect(modelContext.tools.size).toBe(0) + }) + + it('reports asynchronous registration errors', async () => { + installModelContext({ failOn: 'status' }) + const { handle } = createFakeHandle() + const onError = vi.fn() + + createWebMCPTools(handle, [statusTool], { onError }) + + await vi.waitFor(() => expect(onError).toHaveBeenCalledOnce()) + expect(onError).toHaveBeenCalledWith( + new Error('status registration failed'), + ) + }) + + it('does not report a pending registration error after cleanup', async () => { + const modelContext = installModelContext({ keepRegistrationPending: true }) + const { handle, abort } = createFakeHandle() + const onError = vi.fn() + + createWebMCPTools(handle, [statusTool], { onError }) + await vi.waitFor(() => + expect(modelContext.registerTool).toHaveBeenCalledOnce(), + ) + + abort() + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(onError).not.toHaveBeenCalled() + }) + + it('preserves tool-name and runtime-context types', () => { + const contextual = toolDefinition({ + name: 'contextual', + description: 'Read the tenant context', + }).client<{ tenantId: string }>((_input, context) => { + return context.context.tenantId + }) + const tools = [contextual] as const + const options: CreateWebMCPToolsOptions = { + context: { tenantId: 'tenant-1' }, + toolOptions: { contextual: { title: 'Tenant status' } }, + } + + expectTypeOf(options.context).toEqualTypeOf<{ tenantId: string }>() + + const checkTypes = (handle: Pick) => { + // @ts-expect-error contextual tools require context + createWebMCPTools(handle, tools) + createWebMCPTools(handle, tools, options) + createWebMCPTools(handle, tools, { + context: { tenantId: 'tenant-1' }, + toolOptions: { + // @ts-expect-error tool options only accept inferred tool names + unknown_tool: {}, + }, + }) + } + void checkTypes + }) +}) diff --git a/packages/ai-remix/tests/exports.test.ts b/packages/ai-remix/tests/exports.test.ts index 30d4d63543..695635cf47 100644 --- a/packages/ai-remix/tests/exports.test.ts +++ b/packages/ai-remix/tests/exports.test.ts @@ -12,6 +12,7 @@ import { createRealtimeChat, createSummarize, createTranscription, + createWebMCPTools, } from '../src/index' describe('package exports', () => { @@ -28,5 +29,6 @@ describe('package exports', () => { expect(createTranscription).toBeTypeOf('function') expect(createSummarize).toBeTypeOf('function') expect(createAudioRecorder).toBeTypeOf('function') + expect(createWebMCPTools).toBeTypeOf('function') }) }) diff --git a/packages/ai-solid/src/index.ts b/packages/ai-solid/src/index.ts index e0cdb8df9a..3e3f2da75d 100644 --- a/packages/ai-solid/src/index.ts +++ b/packages/ai-solid/src/index.ts @@ -1,6 +1,8 @@ export { useChat } from './use-chat' export { createChatHook } from './create-chat-hook' export { useByok } from './use-byok' +export { useWebMCPTools } from './use-web-mcp-tools' +export type { UseWebMCPToolsOptions } from './use-web-mcp-tools' export type { DeepPartial, UseChatOptions, diff --git a/packages/ai-solid/src/use-web-mcp-tools.ts b/packages/ai-solid/src/use-web-mcp-tools.ts new file mode 100644 index 0000000000..0a33772b92 --- /dev/null +++ b/packages/ai-solid/src/use-web-mcp-tools.ts @@ -0,0 +1,55 @@ +import { onCleanup } from 'solid-js' +import { registerWebMCPTools } from '@tanstack/ai-client' +import type { + AnyClientTool, + InferredClientContext, + RegisterWebMCPToolsOptions, +} from '@tanstack/ai-client' + +/** Options for {@link useWebMCPTools}. */ +export type UseWebMCPToolsOptions< + TTools extends ReadonlyArray, + TContext = InferredClientContext, +> = Omit, 'signal'> & { + /** Receives an asynchronous registration error. */ + onError?: (error: unknown) => void +} + +type UseWebMCPToolsArguments< + TTools extends ReadonlyArray, + TContext, +> = + {} extends UseWebMCPToolsOptions + ? [options?: UseWebMCPToolsOptions] + : [options: UseWebMCPToolsOptions] + +/** + * Registers executable client tools with WebMCP for the current Solid owner. + * + * The owner cleanup removes every tool registered by this call. + * + * @param tools - The executable client tools to expose through WebMCP. + * @param options - Runtime context, per-tool options, and error handling. + * + * @example + * ```ts + * useWebMCPTools([searchProducts]) + * ``` + */ +export function useWebMCPTools< + const TTools extends ReadonlyArray, + TContext = InferredClientContext, +>(tools: TTools, ...[options]: UseWebMCPToolsArguments) { + const controller = new AbortController() + + void registerWebMCPTools(tools, { + ...options, + signal: controller.signal, + }).catch((error) => { + if (!controller.signal.aborted) { + options?.onError?.(error) + } + }) + + onCleanup(() => controller.abort()) +} diff --git a/packages/ai-solid/tests/use-web-mcp-tools.test.ts b/packages/ai-solid/tests/use-web-mcp-tools.test.ts new file mode 100644 index 0000000000..a2a0321c36 --- /dev/null +++ b/packages/ai-solid/tests/use-web-mcp-tools.test.ts @@ -0,0 +1,138 @@ +import { afterEach, describe, expect, expectTypeOf, it, vi } from 'vitest' +import { renderHook } from '@solidjs/testing-library' +import { toolDefinition } from '@tanstack/ai/client' +import { useWebMCPTools } from '../src/index' +import type { UseWebMCPToolsOptions } from '../src/index' + +interface RegisteredWebMCPTool { + name: string +} + +interface ModelContextOptions { + failOn?: string + pendingRegistration?: boolean +} + +function installModelContext({ + failOn, + pendingRegistration, +}: ModelContextOptions = {}) { + const tools = new Map() + const pendingTools = new Set() + const modelContext = { + tools, + pendingTools, + async registerTool( + tool: RegisteredWebMCPTool, + options: { signal: AbortSignal }, + ) { + if (tool.name === failOn) { + throw new Error(`${tool.name} registration failed`) + } + if (pendingRegistration) { + pendingTools.add(tool.name) + return new Promise((_resolve, reject) => { + options.signal.addEventListener( + 'abort', + () => { + pendingTools.delete(tool.name) + reject(options.signal.reason) + }, + { once: true }, + ) + }) + } + + tools.set(tool.name, tool) + options.signal.addEventListener('abort', () => tools.delete(tool.name), { + once: true, + }) + }, + } + + Object.defineProperty(document, 'modelContext', { + configurable: true, + value: modelContext, + }) + return modelContext +} + +const statusTool = toolDefinition({ + name: 'status', + description: 'Get the current status', +}).client(async () => ({ ok: true })) + +afterEach(() => { + Reflect.deleteProperty(document, 'modelContext') +}) + +describe('useWebMCPTools (Solid)', () => { + it('registers tools and removes them when the owner is cleaned up', async () => { + const modelContext = installModelContext() + const { cleanup } = renderHook(() => useWebMCPTools([statusTool])) + + await vi.waitFor(() => expect(modelContext.tools.has('status')).toBe(true)) + cleanup() + + expect(modelContext.tools.size).toBe(0) + }) + + it('reports asynchronous registration errors', async () => { + installModelContext({ failOn: 'status' }) + const onError = vi.fn() + const { cleanup } = renderHook(() => + useWebMCPTools([statusTool], { onError }), + ) + + await vi.waitFor(() => expect(onError).toHaveBeenCalledOnce()) + expect(onError).toHaveBeenCalledWith( + new Error('status registration failed'), + ) + cleanup() + }) + + it('does not report a pending registration rejected by owner cleanup', async () => { + const modelContext = installModelContext({ pendingRegistration: true }) + const onError = vi.fn() + const { cleanup } = renderHook(() => + useWebMCPTools([statusTool], { onError }), + ) + + expect(modelContext.pendingTools.has('status')).toBe(true) + cleanup() + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(modelContext.pendingTools.size).toBe(0) + expect(onError).not.toHaveBeenCalled() + }) + + it('preserves tool-name and runtime-context types', () => { + const contextual = toolDefinition({ + name: 'contextual', + description: 'Read the tenant context', + }).client<{ tenantId: string }>((_input, context) => { + return context.context.tenantId + }) + const tools = [contextual] as const + const options: UseWebMCPToolsOptions = { + context: { tenantId: 'tenant-1' }, + toolOptions: { contextual: { title: 'Tenant status' } }, + } + + expectTypeOf(options.context).toEqualTypeOf<{ tenantId: string }>() + + const checkTypes = () => { + // @ts-expect-error contextual tools require context + useWebMCPTools(tools) + useWebMCPTools(tools, options) + useWebMCPTools(tools, { + context: { tenantId: 'tenant-1' }, + toolOptions: { + // @ts-expect-error tool options only accept inferred tool names + unknown_tool: {}, + }, + }) + } + void checkTypes + }) +}) diff --git a/packages/ai-svelte/src/create-web-mcp-tools.svelte.ts b/packages/ai-svelte/src/create-web-mcp-tools.svelte.ts new file mode 100644 index 0000000000..034883512e --- /dev/null +++ b/packages/ai-svelte/src/create-web-mcp-tools.svelte.ts @@ -0,0 +1,57 @@ +import { onDestroy } from 'svelte' +import { registerWebMCPTools } from '@tanstack/ai-client' +import type { + AnyClientTool, + InferredClientContext, + RegisterWebMCPToolsOptions, +} from '@tanstack/ai-client' + +/** Options for {@link createWebMCPTools}. */ +export type CreateWebMCPToolsOptions< + TTools extends ReadonlyArray, + TContext = InferredClientContext, +> = Omit, 'signal'> & { + /** Receives an asynchronous registration error. */ + onError?: (error: unknown) => void +} + +type CreateWebMCPToolsArguments< + TTools extends ReadonlyArray, + TContext, +> = + {} extends CreateWebMCPToolsOptions + ? [options?: CreateWebMCPToolsOptions] + : [options: CreateWebMCPToolsOptions] + +/** + * Registers executable client tools with WebMCP for the current Svelte component. + * + * Component destruction removes every tool registered by this call. + * + * @param tools - The executable client tools to expose through WebMCP. + * @param options - Runtime context, per-tool options, and error handling. + * + * @example + * ```svelte + * + * ``` + */ +export function createWebMCPTools< + const TTools extends ReadonlyArray, + TContext = InferredClientContext, +>(tools: TTools, ...[options]: CreateWebMCPToolsArguments) { + const controller = new AbortController() + + void registerWebMCPTools(tools, { + ...options, + signal: controller.signal, + }).catch((error) => { + if (!controller.signal.aborted) { + options?.onError?.(error) + } + }) + + onDestroy(() => controller.abort()) +} diff --git a/packages/ai-svelte/src/index.ts b/packages/ai-svelte/src/index.ts index cfff999d40..53d5e4db10 100644 --- a/packages/ai-svelte/src/index.ts +++ b/packages/ai-svelte/src/index.ts @@ -1,6 +1,8 @@ export { createChat } from './create-chat.svelte' export { createChatHook } from './create-chat-hook' export { createByok } from './create-byok.svelte' +export { createWebMCPTools } from './create-web-mcp-tools.svelte' +export type { CreateWebMCPToolsOptions } from './create-web-mcp-tools.svelte' export type { CreateChatOptions, CreateChatReturn, diff --git a/packages/ai-svelte/tests/create-web-mcp-tools.test.ts b/packages/ai-svelte/tests/create-web-mcp-tools.test.ts new file mode 100644 index 0000000000..0191dd2276 --- /dev/null +++ b/packages/ai-svelte/tests/create-web-mcp-tools.test.ts @@ -0,0 +1,142 @@ +import { toolDefinition } from '@tanstack/ai/client' +import { afterEach, describe, expect, expectTypeOf, it, vi } from 'vitest' +import type * as Svelte from 'svelte' +import type { CreateWebMCPToolsOptions } from '../src/create-web-mcp-tools.svelte' + +const svelteClient: typeof Svelte = await import( + // @ts-expect-error The client entry has no declaration, so use the public module type. + '../node_modules/svelte/src/index-client.js' +) +vi.doMock('svelte', () => svelteClient) + +const { mount, unmount } = svelteClient +const { createWebMCPTools } = await import('../src/index') +const { default: WebMCPToolsFixture } = + await import('./fixtures/web-mcp-tools.svelte') + +interface RegisteredWebMCPTool { + name: string +} + +function installModelContext({ + failOn, + keepRegistrationPending = false, +}: { + failOn?: string + keepRegistrationPending?: boolean +} = {}) { + const tools = new Map() + const modelContext = { + tools, + registerTool: vi.fn( + async (tool: RegisteredWebMCPTool, options: { signal: AbortSignal }) => { + if (tool.name === failOn) { + throw new Error(`${tool.name} registration failed`) + } + if (keepRegistrationPending) { + await new Promise((_resolve, reject) => { + options.signal.addEventListener( + 'abort', + () => reject(options.signal.reason), + { once: true }, + ) + }) + } + + tools.set(tool.name, tool) + options.signal.addEventListener( + 'abort', + () => tools.delete(tool.name), + { + once: true, + }, + ) + }, + ), + } + + Object.defineProperty(document, 'modelContext', { + configurable: true, + value: modelContext, + }) + return modelContext +} + +afterEach(() => { + Reflect.deleteProperty(document, 'modelContext') + document.body.replaceChildren() +}) + +describe('createWebMCPTools (Svelte)', () => { + it('removes registered tools when the component unmounts', async () => { + const modelContext = installModelContext() + const component = mount(WebMCPToolsFixture, { target: document.body }) + + await vi.waitFor(() => expect(modelContext.tools.has('status')).toBe(true)) + await unmount(component) + + expect(modelContext.tools.size).toBe(0) + }) + + it('reports asynchronous registration errors', async () => { + installModelContext({ failOn: 'status' }) + const onError = vi.fn() + const component = mount(WebMCPToolsFixture, { + target: document.body, + props: { onError }, + }) + + await vi.waitFor(() => expect(onError).toHaveBeenCalledOnce()) + expect(onError).toHaveBeenCalledWith( + new Error('status registration failed'), + ) + await unmount(component) + }) + + it('does not report a pending registration error after unmount', async () => { + const modelContext = installModelContext({ keepRegistrationPending: true }) + const onError = vi.fn() + const component = mount(WebMCPToolsFixture, { + target: document.body, + props: { onError }, + }) + + await vi.waitFor(() => + expect(modelContext.registerTool).toHaveBeenCalledOnce(), + ) + await unmount(component) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(onError).not.toHaveBeenCalled() + }) + + it('preserves tool-name and runtime-context types', () => { + const contextual = toolDefinition({ + name: 'contextual', + description: 'Read the tenant context', + }).client<{ tenantId: string }>((_input, context) => { + return context.context.tenantId + }) + const tools = [contextual] as const + const options: CreateWebMCPToolsOptions = { + context: { tenantId: 'tenant-1' }, + toolOptions: { contextual: { title: 'Tenant status' } }, + } + + expectTypeOf(options.context).toEqualTypeOf<{ tenantId: string }>() + + const checkTypes = () => { + // @ts-expect-error contextual tools require context + createWebMCPTools(tools) + createWebMCPTools(tools, options) + createWebMCPTools(tools, { + context: { tenantId: 'tenant-1' }, + toolOptions: { + // @ts-expect-error tool options only accept inferred tool names + unknown_tool: {}, + }, + }) + } + void checkTypes + }) +}) diff --git a/packages/ai-svelte/tests/fixtures/web-mcp-tools.svelte b/packages/ai-svelte/tests/fixtures/web-mcp-tools.svelte new file mode 100644 index 0000000000..6fa707a268 --- /dev/null +++ b/packages/ai-svelte/tests/fixtures/web-mcp-tools.svelte @@ -0,0 +1,17 @@ + diff --git a/packages/ai-vue/src/index.ts b/packages/ai-vue/src/index.ts index e0cdb8df9a..3e3f2da75d 100644 --- a/packages/ai-vue/src/index.ts +++ b/packages/ai-vue/src/index.ts @@ -1,6 +1,8 @@ export { useChat } from './use-chat' export { createChatHook } from './create-chat-hook' export { useByok } from './use-byok' +export { useWebMCPTools } from './use-web-mcp-tools' +export type { UseWebMCPToolsOptions } from './use-web-mcp-tools' export type { DeepPartial, UseChatOptions, diff --git a/packages/ai-vue/src/use-web-mcp-tools.ts b/packages/ai-vue/src/use-web-mcp-tools.ts new file mode 100644 index 0000000000..3b59a97843 --- /dev/null +++ b/packages/ai-vue/src/use-web-mcp-tools.ts @@ -0,0 +1,55 @@ +import { onScopeDispose } from 'vue' +import { registerWebMCPTools } from '@tanstack/ai-client' +import type { + AnyClientTool, + InferredClientContext, + RegisterWebMCPToolsOptions, +} from '@tanstack/ai-client' + +/** Options for {@link useWebMCPTools}. */ +export type UseWebMCPToolsOptions< + TTools extends ReadonlyArray, + TContext = InferredClientContext, +> = Omit, 'signal'> & { + /** Receives an asynchronous registration error. */ + onError?: (error: unknown) => void +} + +type UseWebMCPToolsArguments< + TTools extends ReadonlyArray, + TContext, +> = + {} extends UseWebMCPToolsOptions + ? [options?: UseWebMCPToolsOptions] + : [options: UseWebMCPToolsOptions] + +/** + * Registers executable client tools with WebMCP for the current Vue scope. + * + * Scope disposal removes every tool registered by this call. + * + * @param tools - The executable client tools to expose through WebMCP. + * @param options - Runtime context, per-tool options, and error handling. + * + * @example + * ```ts + * useWebMCPTools([searchProducts]) + * ``` + */ +export function useWebMCPTools< + const TTools extends ReadonlyArray, + TContext = InferredClientContext, +>(tools: TTools, ...[options]: UseWebMCPToolsArguments) { + const controller = new AbortController() + + void registerWebMCPTools(tools, { + ...options, + signal: controller.signal, + }).catch((error) => { + if (!controller.signal.aborted) { + options?.onError?.(error) + } + }) + + onScopeDispose(() => controller.abort()) +} diff --git a/packages/ai-vue/tests/use-web-mcp-tools.test.ts b/packages/ai-vue/tests/use-web-mcp-tools.test.ts new file mode 100644 index 0000000000..b7202670c9 --- /dev/null +++ b/packages/ai-vue/tests/use-web-mcp-tools.test.ts @@ -0,0 +1,145 @@ +import { afterEach, describe, expect, expectTypeOf, it, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import { defineComponent } from 'vue' +import { toolDefinition } from '@tanstack/ai/client' +import { useWebMCPTools } from '../src/index' +import type { UseWebMCPToolsOptions } from '../src/index' + +interface RegisteredWebMCPTool { + name: string +} + +interface ModelContextOptions { + failOn?: string + pendingRegistration?: boolean +} + +function installModelContext({ + failOn, + pendingRegistration, +}: ModelContextOptions = {}) { + const tools = new Map() + const pendingTools = new Set() + const modelContext = { + tools, + pendingTools, + async registerTool( + tool: RegisteredWebMCPTool, + options: { signal: AbortSignal }, + ) { + if (tool.name === failOn) { + throw new Error(`${tool.name} registration failed`) + } + if (pendingRegistration) { + pendingTools.add(tool.name) + return new Promise((_resolve, reject) => { + options.signal.addEventListener( + 'abort', + () => { + pendingTools.delete(tool.name) + reject(options.signal.reason) + }, + { once: true }, + ) + }) + } + + tools.set(tool.name, tool) + options.signal.addEventListener('abort', () => tools.delete(tool.name), { + once: true, + }) + }, + } + + Object.defineProperty(document, 'modelContext', { + configurable: true, + value: modelContext, + }) + return modelContext +} + +function mountWebMCPTools(onError?: (error: unknown) => void) { + const Host = defineComponent({ + setup() { + useWebMCPTools([statusTool], { onError }) + return () => null + }, + }) + return mount(Host) +} + +const statusTool = toolDefinition({ + name: 'status', + description: 'Get the current status', +}).client(async () => ({ ok: true })) + +afterEach(() => { + Reflect.deleteProperty(document, 'modelContext') +}) + +describe('useWebMCPTools (Vue)', () => { + it('registers tools and removes them when the scope unmounts', async () => { + const modelContext = installModelContext() + const wrapper = mountWebMCPTools() + + await vi.waitFor(() => expect(modelContext.tools.has('status')).toBe(true)) + wrapper.unmount() + + expect(modelContext.tools.size).toBe(0) + }) + + it('reports asynchronous registration errors', async () => { + installModelContext({ failOn: 'status' }) + const onError = vi.fn() + const wrapper = mountWebMCPTools(onError) + + await vi.waitFor(() => expect(onError).toHaveBeenCalledOnce()) + expect(onError).toHaveBeenCalledWith( + new Error('status registration failed'), + ) + wrapper.unmount() + }) + + it('does not report a pending registration rejected by scope cleanup', async () => { + const modelContext = installModelContext({ pendingRegistration: true }) + const onError = vi.fn() + const wrapper = mountWebMCPTools(onError) + + expect(modelContext.pendingTools.has('status')).toBe(true) + wrapper.unmount() + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(modelContext.pendingTools.size).toBe(0) + expect(onError).not.toHaveBeenCalled() + }) + + it('preserves tool-name and runtime-context types', () => { + const contextual = toolDefinition({ + name: 'contextual', + description: 'Read the tenant context', + }).client<{ tenantId: string }>((_input, context) => { + return context.context.tenantId + }) + const tools = [contextual] as const + const options: UseWebMCPToolsOptions = { + context: { tenantId: 'tenant-1' }, + toolOptions: { contextual: { title: 'Tenant status' } }, + } + + expectTypeOf(options.context).toEqualTypeOf<{ tenantId: string }>() + + const checkTypes = () => { + // @ts-expect-error contextual tools require context + useWebMCPTools(tools) + useWebMCPTools(tools, options) + useWebMCPTools(tools, { + context: { tenantId: 'tenant-1' }, + toolOptions: { + // @ts-expect-error tool options only accept inferred tool names + unknown_tool: {}, + }, + }) + } + void checkTypes + }) +}) diff --git a/testing/e2e/src/routeTree.gen.ts b/testing/e2e/src/routeTree.gen.ts index 280c7cf4d9..1ed21212f5 100644 --- a/testing/e2e/src/routeTree.gen.ts +++ b/testing/e2e/src/routeTree.gen.ts @@ -10,6 +10,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as WebsocketAdapterRouteImport } from './routes/websocket-adapter' +import { Route as WebMcpToolsRouteImport } from './routes/web-mcp-tools' import { Route as ToolsTestRouteImport } from './routes/tools-test' import { Route as ToolFirstTextRouteImport } from './routes/tool-first-text' import { Route as TextFirstToolRouteImport } from './routes/text-first-tool' @@ -106,6 +107,11 @@ const WebsocketAdapterRoute = WebsocketAdapterRouteImport.update({ path: '/websocket-adapter', getParentRoute: () => rootRouteImport, } as any) +const WebMcpToolsRoute = WebMcpToolsRouteImport.update({ + id: '/web-mcp-tools', + path: '/web-mcp-tools', + getParentRoute: () => rootRouteImport, +} as any) const ToolsTestRoute = ToolsTestRouteImport.update({ id: '/tools-test', path: '/tools-test', @@ -599,6 +605,7 @@ export interface FileRoutesByFullPath { '/text-first-tool': typeof TextFirstToolRoute '/tool-first-text': typeof ToolFirstTextRoute '/tools-test': typeof ToolsTestRoute + '/web-mcp-tools': typeof WebMcpToolsRoute '/websocket-adapter': typeof WebsocketAdapterRoute '/$provider/$feature': typeof ProviderFeatureRoute '/api/anthropic-bug-test': typeof ApiAnthropicBugTestRoute @@ -692,6 +699,7 @@ export interface FileRoutesByTo { '/text-first-tool': typeof TextFirstToolRoute '/tool-first-text': typeof ToolFirstTextRoute '/tools-test': typeof ToolsTestRoute + '/web-mcp-tools': typeof WebMcpToolsRoute '/websocket-adapter': typeof WebsocketAdapterRoute '/$provider/$feature': typeof ProviderFeatureRoute '/api/anthropic-bug-test': typeof ApiAnthropicBugTestRoute @@ -786,6 +794,7 @@ export interface FileRoutesById { '/text-first-tool': typeof TextFirstToolRoute '/tool-first-text': typeof ToolFirstTextRoute '/tools-test': typeof ToolsTestRoute + '/web-mcp-tools': typeof WebMcpToolsRoute '/websocket-adapter': typeof WebsocketAdapterRoute '/$provider/$feature': typeof ProviderFeatureRoute '/api/anthropic-bug-test': typeof ApiAnthropicBugTestRoute @@ -881,6 +890,7 @@ export interface FileRouteTypes { | '/text-first-tool' | '/tool-first-text' | '/tools-test' + | '/web-mcp-tools' | '/websocket-adapter' | '/$provider/$feature' | '/api/anthropic-bug-test' @@ -974,6 +984,7 @@ export interface FileRouteTypes { | '/text-first-tool' | '/tool-first-text' | '/tools-test' + | '/web-mcp-tools' | '/websocket-adapter' | '/$provider/$feature' | '/api/anthropic-bug-test' @@ -1067,6 +1078,7 @@ export interface FileRouteTypes { | '/text-first-tool' | '/tool-first-text' | '/tools-test' + | '/web-mcp-tools' | '/websocket-adapter' | '/$provider/$feature' | '/api/anthropic-bug-test' @@ -1161,6 +1173,7 @@ export interface RootRouteChildren { TextFirstToolRoute: typeof TextFirstToolRoute ToolFirstTextRoute: typeof ToolFirstTextRoute ToolsTestRoute: typeof ToolsTestRoute + WebMcpToolsRoute: typeof WebMcpToolsRoute WebsocketAdapterRoute: typeof WebsocketAdapterRoute ProviderFeatureRoute: typeof ProviderFeatureRoute ApiAnthropicBugTestRoute: typeof ApiAnthropicBugTestRoute @@ -1236,6 +1249,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof WebsocketAdapterRouteImport parentRoute: typeof rootRouteImport } + '/web-mcp-tools': { + id: '/web-mcp-tools' + path: '/web-mcp-tools' + fullPath: '/web-mcp-tools' + preLoaderRoute: typeof WebMcpToolsRouteImport + parentRoute: typeof rootRouteImport + } '/tools-test': { id: '/tools-test' path: '/tools-test' @@ -1950,6 +1970,7 @@ const rootRouteChildren: RootRouteChildren = { TextFirstToolRoute: TextFirstToolRoute, ToolFirstTextRoute: ToolFirstTextRoute, ToolsTestRoute: ToolsTestRoute, + WebMcpToolsRoute: WebMcpToolsRoute, WebsocketAdapterRoute: WebsocketAdapterRoute, ProviderFeatureRoute: ProviderFeatureRoute, ApiAnthropicBugTestRoute: ApiAnthropicBugTestRoute, diff --git a/testing/e2e/src/routes/web-mcp-tools.tsx b/testing/e2e/src/routes/web-mcp-tools.tsx new file mode 100644 index 0000000000..2212e806ca --- /dev/null +++ b/testing/e2e/src/routes/web-mcp-tools.tsx @@ -0,0 +1,182 @@ +import { useEffect, useState } from 'react' +import { createFileRoute } from '@tanstack/react-router' +import { toolDefinition } from '@tanstack/ai' +import { useWebMCPTools } from '@tanstack/ai-react' +import { z } from 'zod' + +interface RegisteredWebMCPTool { + name: string +} + +interface WebMCPModelContext extends EventTarget { + getTools: () => Promise> + executeTool: ( + tool: RegisteredWebMCPTool, + input: object, + options: { signal: AbortSignal }, + ) => Promise +} + +function isWebMCPModelContext(value: unknown): value is WebMCPModelContext { + if (!(value instanceof EventTarget)) return false + + const hasGetTools = + 'getTools' in value && typeof value.getTools === 'function' + const hasExecuteTool = + 'executeTool' in value && typeof value.executeTool === 'function' + return hasGetTools && hasExecuteTool +} + +function getWebMCPModelContext() { + if (typeof document === 'undefined' || !('modelContext' in document)) { + return + } + + const modelContext = document.modelContext + if (!isWebMCPModelContext(modelContext)) { + return + } + + return modelContext +} + +const findGuitar = toolDefinition({ + name: 'find_guitar', + description: 'Find a guitar in the local test catalog.', + inputSchema: z.object({ query: z.string() }), + outputSchema: z.object({ message: z.string() }), +}).client((input) => ({ message: `Found ${input.query}` })) + +const tools = [findGuitar] as const +const webMCPOptions = { + toolOptions: { + find_guitar: { + title: 'Find guitar', + annotations: { readOnlyHint: true }, + }, + }, +} + +export const Route = createFileRoute('/web-mcp-tools')({ + component: WebMCPToolsPage, +}) + +function ToolOwner() { + useWebMCPTools(tools, webMCPOptions) + return null +} + +function WebMCPToolsPage() { + const [ownerMounted, setOwnerMounted] = useState(true) + const [registeredCount, setRegisteredCount] = useState(0) + const [toolResult, setToolResult] = useState('Not run') + + useEffect(() => { + const modelContext = getWebMCPModelContext() + if (!modelContext) return + + let active = true + const updateRegisteredCount = async () => { + const registeredTools = await modelContext.getTools() + if (active) { + setRegisteredCount(registeredTools.length) + } + } + const handleToolChange = () => { + void updateRegisteredCount() + } + + modelContext.addEventListener('toolchange', handleToolChange) + void updateRegisteredCount() + + return () => { + active = false + modelContext.removeEventListener('toolchange', handleToolChange) + } + }, []) + + const executeTool = async () => { + const modelContext = getWebMCPModelContext() + if (!modelContext) { + setToolResult('WebMCP unavailable') + return + } + + const registeredTools = await modelContext.getTools() + const tool = registeredTools.find((item) => item.name === 'find_guitar') + if (!tool) { + setToolResult('Tool not found') + return + } + + const execution = new AbortController() + const serializedResult = await modelContext.executeTool( + tool, + { query: 'guitar' }, + { signal: execution.signal }, + ) + let result: unknown + try { + result = JSON.parse(serializedResult) + } catch { + setToolResult('Invalid tool result') + return + } + + if ( + result !== null && + typeof result === 'object' && + 'message' in result && + typeof result.message === 'string' + ) { + setToolResult(result.message) + return + } + + setToolResult('Invalid tool result') + } + + return ( +
+

+ WebMCP tools +

+ + {ownerMounted ? : null} + +

+ Registered tools:{' '} + + {registeredCount} + +

+

+ Tool result:{' '} + + {toolResult} + +

+ +
+ + +
+
+ ) +} diff --git a/testing/e2e/tests/web-mcp-tools.spec.ts b/testing/e2e/tests/web-mcp-tools.spec.ts new file mode 100644 index 0000000000..dc60a94c21 --- /dev/null +++ b/testing/e2e/tests/web-mcp-tools.spec.ts @@ -0,0 +1,120 @@ +import { expect, test } from '@playwright/test' + +interface WebMCPToolRegistration { + name: string + title?: string + description: string + inputSchema?: object + annotations?: { + readOnlyHint?: boolean + untrustedContentHint?: boolean + } + execute: (input: object, options: { signal: AbortSignal }) => Promise +} + +interface RegisteredWebMCPTool { + name: string + title: string + description: string + inputSchema?: object + window: Window + origin: string + annotations?: { + readOnlyHint?: boolean + untrustedContentHint?: boolean + } +} + +interface WebMCPRegistration { + descriptor: RegisteredWebMCPTool + execute: WebMCPToolRegistration['execute'] +} + +test.beforeEach(async ({ page }) => { + await page.addInitScript(() => { + const registrations = new Map() + const modelContext = new (class extends EventTarget { + async registerTool( + tool: WebMCPToolRegistration, + options: { signal: AbortSignal }, + ) { + if (options.signal.aborted) throw options.signal.reason + if (registrations.has(tool.name)) { + throw new DOMException('Tool already registered', 'InvalidStateError') + } + + const descriptor: RegisteredWebMCPTool = { + name: tool.name, + title: tool.title ?? '', + description: tool.description, + ...(tool.inputSchema === undefined + ? {} + : { inputSchema: structuredClone(tool.inputSchema) }), + window, + origin: location.origin, + ...(tool.annotations === undefined + ? {} + : { annotations: { ...tool.annotations } }), + } + const registration = { descriptor, execute: tool.execute } + options.signal.addEventListener( + 'abort', + () => { + if (registrations.get(tool.name) !== registration) return + + registrations.delete(tool.name) + this.dispatchEvent(new Event('toolchange')) + }, + { once: true }, + ) + + registrations.set(tool.name, registration) + this.dispatchEvent(new Event('toolchange')) + } + + async getTools() { + return [...registrations.values()].map(({ descriptor }) => ({ + ...descriptor, + })) + } + + async executeTool( + tool: RegisteredWebMCPTool, + input: object, + options: { signal: AbortSignal }, + ) { + const registration = registrations.get(tool.name) + if (!registration) { + throw new DOMException('Tool not found', 'NotFoundError') + } + + const result = await registration.execute(input, options) + const serializedResult = JSON.stringify(result) + if (serializedResult === undefined) { + throw new TypeError('Tool result is not JSON serializable') + } + + return serializedResult + } + })() + + Object.defineProperty(document, 'modelContext', { + configurable: true, + value: modelContext, + }) + }) +}) + +test('WebMCP discovers, executes, and removes a React tool', async ({ + page, +}) => { + await page.goto('/web-mcp-tools') + + await expect(page.getByTestId('registered-count')).toHaveText('1') + + await page.getByRole('button', { name: 'Execute WebMCP tool' }).click() + await expect(page.getByTestId('tool-result')).toHaveText('Found guitar') + + await page.getByRole('button', { name: 'Unmount tool owner' }).press('Enter') + await expect(page.getByTestId('registered-count')).toHaveText('0') +})