@assistant-ui/react 0.15.17 → 0.15.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/README.md +1 -1
  2. package/dist/index.d.ts +6 -3
  3. package/dist/index.js +4 -2
  4. package/dist/mcp-apps/McpAppRenderer.d.ts +30 -8
  5. package/dist/mcp-apps/McpAppRenderer.d.ts.map +1 -1
  6. package/dist/mcp-apps/McpAppRenderer.js +91 -47
  7. package/dist/mcp-apps/McpAppRenderer.js.map +1 -1
  8. package/dist/mcp-apps/app-frame.d.ts.map +1 -1
  9. package/dist/mcp-apps/app-frame.js +41 -22
  10. package/dist/mcp-apps/app-frame.js.map +1 -1
  11. package/dist/mcp-apps/bridge.js +2 -3
  12. package/dist/mcp-apps/bridge.js.map +1 -1
  13. package/dist/mcp-apps/index.d.ts +2 -2
  14. package/dist/primitives/composer/ComposerInputPluginContext.d.ts +1 -2
  15. package/dist/primitives/composer/ComposerInputPluginContext.d.ts.map +1 -1
  16. package/dist/primitives/composer/ComposerInputPluginContext.js +1 -6
  17. package/dist/primitives/composer/ComposerInputPluginContext.js.map +1 -1
  18. package/dist/primitives/composer/trigger/TriggerPopoverAction.js +46 -15
  19. package/dist/primitives/composer/trigger/TriggerPopoverAction.js.map +1 -1
  20. package/dist/primitives/composer/trigger/TriggerPopoverDirective.js +40 -10
  21. package/dist/primitives/composer/trigger/TriggerPopoverDirective.js.map +1 -1
  22. package/dist/primitives/composer/trigger/matchesTriggerItemQuery.d.ts +6 -0
  23. package/dist/primitives/composer/trigger/matchesTriggerItemQuery.d.ts.map +1 -0
  24. package/dist/primitives/composer/trigger/matchesTriggerItemQuery.js +9 -0
  25. package/dist/primitives/composer/trigger/matchesTriggerItemQuery.js.map +1 -0
  26. package/dist/primitives/composer/trigger/triggerNavigationResource.d.ts.map +1 -1
  27. package/dist/primitives/composer/trigger/triggerNavigationResource.js +3 -5
  28. package/dist/primitives/composer/trigger/triggerNavigationResource.js.map +1 -1
  29. package/dist/primitives/messagePart/MessagePartText.d.ts +1 -1
  30. package/dist/primitives/thread/useThreadViewportAutoScroll.d.ts.map +1 -1
  31. package/dist/primitives/thread/useThreadViewportAutoScroll.js +3 -3
  32. package/dist/primitives/thread/useThreadViewportAutoScroll.js.map +1 -1
  33. package/dist/sandbox-host/SandboxHost.d.ts.map +1 -1
  34. package/dist/sandbox-host/SandboxHost.js +14 -5
  35. package/dist/sandbox-host/SandboxHost.js.map +1 -1
  36. package/dist/unstable/useMentionAdapter.d.ts +9 -3
  37. package/dist/unstable/useMentionAdapter.d.ts.map +1 -1
  38. package/dist/unstable/useMentionAdapter.js +50 -43
  39. package/dist/unstable/useMentionAdapter.js.map +1 -1
  40. package/dist/unstable/useModelContextSnapshot.d.ts +34 -0
  41. package/dist/unstable/useModelContextSnapshot.d.ts.map +1 -0
  42. package/dist/unstable/useModelContextSnapshot.js +61 -0
  43. package/dist/unstable/useModelContextSnapshot.js.map +1 -0
  44. package/dist/unstable/useSlashCommandAdapter.d.ts.map +1 -1
  45. package/dist/unstable/useSlashCommandAdapter.js +2 -8
  46. package/dist/unstable/useSlashCommandAdapter.js.map +1 -1
  47. package/dist/unstable/webmcp/WebMcpRegistrationResource.d.ts +14 -0
  48. package/dist/unstable/webmcp/WebMcpRegistrationResource.d.ts.map +1 -0
  49. package/dist/unstable/webmcp/WebMcpRegistrationResource.js +85 -0
  50. package/dist/unstable/webmcp/WebMcpRegistrationResource.js.map +1 -0
  51. package/dist/unstable/webmcp/convertTools.d.ts +21 -0
  52. package/dist/unstable/webmcp/convertTools.d.ts.map +1 -0
  53. package/dist/unstable/webmcp/convertTools.js +110 -0
  54. package/dist/unstable/webmcp/convertTools.js.map +1 -0
  55. package/dist/unstable/webmcp/useWebMcpProvider.d.ts +21 -0
  56. package/dist/unstable/webmcp/useWebMcpProvider.d.ts.map +1 -0
  57. package/dist/unstable/webmcp/useWebMcpProvider.js +105 -0
  58. package/dist/unstable/webmcp/useWebMcpProvider.js.map +1 -0
  59. package/dist/unstable/webmcp/webmcp-host.d.ts +39 -0
  60. package/dist/unstable/webmcp/webmcp-host.d.ts.map +1 -0
  61. package/dist/unstable/webmcp/webmcp-host.js +29 -0
  62. package/dist/unstable/webmcp/webmcp-host.js.map +1 -0
  63. package/dist/utils/Primitive.d.ts +4 -4
  64. package/package.json +12 -12
  65. package/src/index.ts +13 -0
  66. package/src/mcp-apps/McpAppRenderer.test.tsx +253 -5
  67. package/src/mcp-apps/McpAppRenderer.tsx +147 -52
  68. package/src/mcp-apps/app-frame.test.tsx +248 -1
  69. package/src/mcp-apps/app-frame.tsx +51 -5
  70. package/src/mcp-apps/bridge.ts +7 -2
  71. package/src/mcp-apps/index.ts +5 -1
  72. package/src/primitives/composer/ComposerInputPluginContext.tsx +0 -10
  73. package/src/primitives/composer/trigger/TriggerPopoverAction.tsx +4 -2
  74. package/src/primitives/composer/trigger/TriggerPopoverBehavior.test.tsx +109 -0
  75. package/src/primitives/composer/trigger/TriggerPopoverDirective.tsx +4 -2
  76. package/src/primitives/composer/trigger/matchesTriggerItemQuery.test.ts +52 -0
  77. package/src/primitives/composer/trigger/matchesTriggerItemQuery.ts +13 -0
  78. package/src/primitives/composer/trigger/triggerNavigationResource.ts +3 -10
  79. package/src/primitives/thread/useThreadViewportAutoScroll.ts +7 -6
  80. package/src/sandbox-host/SandboxHost.test.tsx +120 -2
  81. package/src/sandbox-host/SandboxHost.tsx +26 -6
  82. package/src/unstable/useComposerInputHistory.test.tsx +2 -1
  83. package/src/unstable/useMentionAdapter.test.tsx +337 -0
  84. package/src/unstable/useMentionAdapter.ts +94 -57
  85. package/src/unstable/useModelContextSnapshot.test.tsx +132 -0
  86. package/src/unstable/useModelContextSnapshot.ts +67 -0
  87. package/src/unstable/useSlashCommandAdapter.ts +2 -9
  88. package/src/unstable/webmcp/WebMcpRegistrationResource.ts +92 -0
  89. package/src/unstable/webmcp/__tests__/webmcp.fake.tsx +168 -0
  90. package/src/unstable/webmcp/convertTools.test.ts +393 -0
  91. package/src/unstable/webmcp/convertTools.ts +180 -0
  92. package/src/unstable/webmcp/useWebMcpProvider.test.tsx +466 -0
  93. package/src/unstable/webmcp/useWebMcpProvider.ts +150 -0
  94. package/src/unstable/webmcp/webmcp-host.test.ts +142 -0
  95. package/src/unstable/webmcp/webmcp-host.ts +83 -0
  96. package/dist/utils/invokeCallbackSafely.d.ts +0 -5
  97. package/dist/utils/invokeCallbackSafely.d.ts.map +0 -1
  98. package/dist/utils/invokeCallbackSafely.js +0 -15
  99. package/dist/utils/invokeCallbackSafely.js.map +0 -1
  100. package/dist/utils/useToolArgsFieldStatus.d.ts +0 -19
  101. package/dist/utils/useToolArgsFieldStatus.d.ts.map +0 -1
  102. package/dist/utils/useToolArgsFieldStatus.js +0 -23
  103. package/dist/utils/useToolArgsFieldStatus.js.map +0 -1
  104. package/src/utils/invokeCallbackSafely.ts +0 -11
  105. package/src/utils/useToolArgsFieldStatus.ts +0 -18
@@ -0,0 +1,393 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { z } from "zod";
3
+ import { ToolResponse, type Tool } from "assistant-stream";
4
+ import {
5
+ defaultWebMcpFilter,
6
+ toMcpContent,
7
+ toWebMcpTool,
8
+ } from "./convertTools";
9
+
10
+ const jsonSchema = {
11
+ type: "object",
12
+ properties: { city: { type: "string" } },
13
+ required: ["city"],
14
+ } as const;
15
+
16
+ const frontendTool = (
17
+ overrides: Partial<Tool<any, any>> = {},
18
+ ): Tool<any, any> =>
19
+ ({
20
+ type: "frontend",
21
+ description: "Get the weather for a city.",
22
+ parameters: jsonSchema,
23
+ execute: async ({ city }: { city: string }) => `Sunny in ${city}`,
24
+ ...overrides,
25
+ }) as Tool<any, any>;
26
+
27
+ const descriptorFor = (
28
+ overrides: Partial<Tool<any, any>> = {},
29
+ lifecycleSignal?: AbortSignal,
30
+ ) => toWebMcpTool("t", () => frontendTool(overrides), lifecycleSignal);
31
+
32
+ const text = (value: string) => ({ type: "text", text: value });
33
+
34
+ describe("defaultWebMcpFilter", () => {
35
+ it.for([
36
+ ["exposes an enabled frontend tool", {}, true],
37
+ ["hides a backend tool", { type: "backend" }, false],
38
+ ["hides a frontend tool with no execute", { execute: undefined }, false],
39
+ ["hides a disabled frontend tool", { disabled: true }, false],
40
+ ["exposes a tool authored without a type", { type: undefined }, true],
41
+ [
42
+ "hides a type-less tool with no execute",
43
+ { type: undefined, execute: undefined },
44
+ false,
45
+ ],
46
+ ] as const)("%s", ([, overrides, expected]) => {
47
+ expect(defaultWebMcpFilter("t", frontendTool(overrides as any))).toBe(
48
+ expected,
49
+ );
50
+ });
51
+ });
52
+
53
+ describe("toWebMcpTool descriptor", () => {
54
+ it("projects name, description, and the input schema", () => {
55
+ const descriptor = toWebMcpTool("get_weather", () => frontendTool());
56
+ expect(descriptor.name).toBe("get_weather");
57
+ expect(descriptor.description).toBe("Get the weather for a city.");
58
+ expect(descriptor.inputSchema).toEqual(jsonSchema);
59
+
60
+ expect(
61
+ descriptorFor({ parameters: z.object({ city: z.string() }) }).inputSchema,
62
+ ).toMatchObject(jsonSchema);
63
+
64
+ const bare = descriptorFor({
65
+ description: undefined,
66
+ parameters: undefined,
67
+ });
68
+ expect(bare.description).toBe("");
69
+ expect(bare.inputSchema).toEqual({ type: "object", properties: {} });
70
+ });
71
+
72
+ it("throws at construction for a schema that cannot convert", () => {
73
+ const badSchema = {
74
+ "~standard": { version: 1, validate: () => ({ issues: undefined }) },
75
+ };
76
+ expect(() => descriptorFor({ parameters: badSchema as any })).toThrow();
77
+ });
78
+ });
79
+
80
+ describe("toWebMcpTool execute", () => {
81
+ it("passes the arguments through, defaulting missing arguments to {}", async () => {
82
+ const execute = vi.fn(async () => "Sunny in Paris");
83
+ const descriptor = descriptorFor({ execute, parameters: undefined });
84
+
85
+ const result = await descriptor.execute({ city: "Paris" });
86
+ expect(execute).toHaveBeenCalledWith(
87
+ { city: "Paris" },
88
+ expect.objectContaining({ toolCallId: expect.any(String) }),
89
+ );
90
+ expect(result).toEqual({ content: [text("Sunny in Paris")] });
91
+
92
+ await descriptor.execute(undefined);
93
+ expect(execute).toHaveBeenLastCalledWith({}, expect.anything());
94
+ });
95
+
96
+ it.for([
97
+ ["serializes a non-string result", { ok: true }, '{"ok":true}'],
98
+ [
99
+ "falls back to String() for an unserializable result",
100
+ Symbol("opaque"),
101
+ "Symbol(opaque)",
102
+ ],
103
+ ] as const)("%s", async ([, value, expected]) => {
104
+ const result = await descriptorFor({
105
+ execute: async () => value,
106
+ }).execute({});
107
+ expect(result).toEqual({ content: [text(expected)] });
108
+ });
109
+
110
+ it("reports a thrown value as an error result", async () => {
111
+ const throwing = (value: unknown) =>
112
+ descriptorFor({
113
+ execute: async () => {
114
+ throw value;
115
+ },
116
+ });
117
+ await expect(throwing(new Error("boom")).execute({})).resolves.toEqual({
118
+ isError: true,
119
+ content: [text("boom")],
120
+ });
121
+ await expect(throwing("raw string boom").execute({})).resolves.toEqual({
122
+ isError: true,
123
+ content: [text("raw string boom")],
124
+ });
125
+ await expect(throwing({ code: 500 }).execute({})).resolves.toEqual({
126
+ isError: true,
127
+ content: [text("[object Object]")],
128
+ });
129
+
130
+ const circular: Record<string, unknown> = { a: 1 };
131
+ circular["self"] = circular;
132
+ const unserializable = await descriptorFor({
133
+ execute: async () => circular,
134
+ }).execute({});
135
+ expect(unserializable.isError).toBe(true);
136
+ });
137
+
138
+ it("reports an error when a published tool has no client-side execute", async () => {
139
+ await expect(
140
+ descriptorFor({ execute: undefined }).execute({}),
141
+ ).resolves.toEqual({
142
+ isError: true,
143
+ content: [text('Tool "t" has no client-side implementation.')],
144
+ });
145
+ });
146
+
147
+ it("projects values JSON cannot serialize but that have a string form", async () => {
148
+ await expect(
149
+ descriptorFor({ execute: async () => 9007199254740993n }).execute({}),
150
+ ).resolves.toEqual({ content: [text("9007199254740993")] });
151
+ await expect(
152
+ descriptorFor({ execute: async () => Symbol("ticket") }).execute({}),
153
+ ).resolves.toEqual({ content: [text("Symbol(ticket)")] });
154
+ });
155
+
156
+ it("rejects human input requests", async () => {
157
+ const result = await descriptorFor({
158
+ execute: async (_args: unknown, context: any) => await context.human(),
159
+ }).execute({});
160
+ expect(result).toEqual({
161
+ isError: true,
162
+ content: [text("human input not supported in WebMCP context")],
163
+ });
164
+ });
165
+ });
166
+
167
+ describe("toWebMcpTool schema validation", () => {
168
+ const zodTool = (overrides: Partial<Tool<any, any>> = {}) =>
169
+ descriptorFor({
170
+ parameters: z.object({ city: z.string() }),
171
+ ...overrides,
172
+ });
173
+
174
+ it("runs execute unchanged when the arguments validate", async () => {
175
+ const execute = vi.fn(async () => "ok");
176
+ const result = await zodTool({ execute }).execute({ city: "Paris" });
177
+ expect(execute).toHaveBeenCalledWith({ city: "Paris" }, expect.anything());
178
+ expect(result).toEqual({ content: [text("ok")] });
179
+ });
180
+
181
+ it("returns a validation error when the arguments do not validate", async () => {
182
+ const execute = vi.fn(async () => "ok");
183
+ const result = await zodTool({ execute }).execute({ city: 42 });
184
+ expect(execute).not.toHaveBeenCalled();
185
+ expect(result.isError).toBe(true);
186
+ expect(result.content[0]).toMatchObject({
187
+ text: expect.stringContaining("Function parameter validation failed."),
188
+ });
189
+ });
190
+
191
+ it("hands invalid arguments to experimental_onSchemaValidationError", async () => {
192
+ const execute = vi.fn(async () => "ok");
193
+ const result = await zodTool({
194
+ execute,
195
+ experimental_onSchemaValidationError: async () => "recovered",
196
+ }).execute({ city: 42 });
197
+ expect(execute).not.toHaveBeenCalled();
198
+ expect(result).toEqual({ content: [text("recovered")] });
199
+ });
200
+
201
+ it("awaits a validator that returns a non-Promise thenable", async () => {
202
+ const execute = vi.fn(async () => "ok");
203
+ const schema = z.object({ city: z.string() });
204
+ (schema as any)["~standard"] = {
205
+ ...schema["~standard"],
206
+ validate: () => ({
207
+ then: (resolve: (value: { issues: unknown[] }) => void) => {
208
+ resolve({ issues: [{ message: "cross-realm" }] });
209
+ },
210
+ }),
211
+ };
212
+
213
+ const result = await descriptorFor({ execute, parameters: schema }).execute(
214
+ { city: 42 },
215
+ );
216
+ expect(execute).not.toHaveBeenCalled();
217
+ expect(result.isError).toBe(true);
218
+ expect(result.content[0]).toMatchObject({
219
+ text: expect.stringContaining("cross-realm"),
220
+ });
221
+ });
222
+
223
+ it("awaits an async Standard Schema validation", async () => {
224
+ const execute = vi.fn(async () => "ok");
225
+ const schema = z.object({ city: z.string() });
226
+ const sync = schema["~standard"].validate;
227
+ (schema as any)["~standard"] = {
228
+ ...schema["~standard"],
229
+ validate: async (value: unknown) => sync(value),
230
+ };
231
+
232
+ const result = await descriptorFor({ execute, parameters: schema }).execute(
233
+ { city: 42 },
234
+ );
235
+ expect(execute).not.toHaveBeenCalled();
236
+ expect(result.isError).toBe(true);
237
+ });
238
+ });
239
+
240
+ describe("toWebMcpTool cancellation", () => {
241
+ it("refuses to run once its lifecycle signal is aborted", async () => {
242
+ const execute = vi.fn(async () => "never");
243
+ const controller = new AbortController();
244
+ controller.abort();
245
+ const result = await descriptorFor({ execute }, controller.signal).execute(
246
+ {},
247
+ );
248
+ expect(execute).not.toHaveBeenCalled();
249
+ expect(result).toEqual({
250
+ isError: true,
251
+ content: [text('Tool "t" is no longer registered')],
252
+ });
253
+ });
254
+
255
+ it("refuses to run when the caller signal is already aborted", async () => {
256
+ const execute = vi.fn(async () => "never");
257
+ const controller = new AbortController();
258
+ controller.abort();
259
+ const result = await descriptorFor({ execute }).execute(
260
+ {},
261
+ { signal: controller.signal },
262
+ );
263
+ expect(execute).not.toHaveBeenCalled();
264
+ expect(result).toEqual({
265
+ isError: true,
266
+ content: [text("Tool execution was cancelled.")],
267
+ });
268
+ });
269
+
270
+ it("merges the caller signal with the lifecycle signal", async () => {
271
+ const lifecycle = new AbortController();
272
+ const caller = new AbortController();
273
+ const descriptor = descriptorFor(
274
+ {
275
+ execute: async (_args: unknown, context: any) =>
276
+ new Promise((_resolve, reject) => {
277
+ context.abortSignal.addEventListener("abort", () =>
278
+ reject(new Error("aborted")),
279
+ );
280
+ }),
281
+ },
282
+ lifecycle.signal,
283
+ );
284
+
285
+ const pending = descriptor.execute({}, { signal: caller.signal });
286
+ lifecycle.abort();
287
+ await expect(pending).resolves.toEqual({
288
+ isError: true,
289
+ content: [text("aborted")],
290
+ });
291
+ });
292
+
293
+ it("returns an error result when the caller signal cannot be merged", async () => {
294
+ const execute = vi.fn(async () => "never");
295
+ const foreignSignal = {
296
+ aborted: false,
297
+ addEventListener: () => {},
298
+ removeEventListener: () => {},
299
+ } as unknown as AbortSignal;
300
+
301
+ const result = await descriptorFor(
302
+ { execute },
303
+ new AbortController().signal,
304
+ ).execute({}, { signal: foreignSignal });
305
+ expect(result.isError).toBe(true);
306
+ expect(execute).not.toHaveBeenCalled();
307
+ });
308
+ });
309
+
310
+ describe("toMcpContent", () => {
311
+ const options = { tool: frontendTool(), toolCallId: "1", args: {} };
312
+
313
+ it.for([
314
+ ["text parts", [{ type: "text", text: "hello" }], [text("hello")]],
315
+ [
316
+ "image file parts",
317
+ [{ type: "file", data: "AAA", mediaType: "image/png" }],
318
+ [{ type: "image", data: "AAA", mimeType: "image/png" }],
319
+ ],
320
+ [
321
+ "image file parts with no data",
322
+ [{ type: "file", mediaType: "image/png" }],
323
+ [{ type: "image", data: "", mimeType: "image/png" }],
324
+ ],
325
+ [
326
+ "non-image file parts",
327
+ [{ type: "file", data: "raw", mediaType: "text/plain" }],
328
+ [text("raw")],
329
+ ],
330
+ [
331
+ "unknown parts",
332
+ [{ type: "reasoning", text: "why" }],
333
+ [text('{"type":"reasoning","text":"why"}')],
334
+ ],
335
+ ] as const)("maps %s", async ([, modelContent, expected]) => {
336
+ const response = await toMcpContent(
337
+ new ToolResponse({ result: "r", modelContent: modelContent as any }),
338
+ options,
339
+ );
340
+ expect(response).toEqual({ content: expected });
341
+ });
342
+
343
+ it("marks an error response, with or without an explicit modelContent", async () => {
344
+ await expect(
345
+ toMcpContent(
346
+ new ToolResponse({
347
+ result: "r",
348
+ isError: true,
349
+ modelContent: [{ type: "text", text: "failed" }],
350
+ }),
351
+ options,
352
+ ),
353
+ ).resolves.toEqual({ isError: true, content: [text("failed")] });
354
+
355
+ await expect(
356
+ toMcpContent(
357
+ new ToolResponse({ result: "went wrong", isError: true }),
358
+ options,
359
+ ),
360
+ ).resolves.toEqual({ isError: true, content: [text("went wrong")] });
361
+ });
362
+
363
+ it("projects a successful result through toModelOutput", async () => {
364
+ const toModelOutput = vi.fn(async () => [
365
+ { type: "text", text: "projected" },
366
+ ]);
367
+ const response = await toMcpContent("raw", {
368
+ ...options,
369
+ tool: frontendTool({ toModelOutput } as any),
370
+ });
371
+ expect(toModelOutput).toHaveBeenCalledWith({
372
+ toolCallId: "1",
373
+ input: {},
374
+ output: "raw",
375
+ });
376
+ expect(response).toEqual({ content: [text("projected")] });
377
+ });
378
+
379
+ it("falls back to the default projection when toModelOutput throws", async () => {
380
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
381
+ const response = await toMcpContent("raw", {
382
+ ...options,
383
+ tool: frontendTool({
384
+ toModelOutput: () => {
385
+ throw new Error("bad projection");
386
+ },
387
+ } as any),
388
+ });
389
+ expect(response).toEqual({ content: [text("raw")] });
390
+ expect(warn).toHaveBeenCalled();
391
+ warn.mockRestore();
392
+ });
393
+ });
@@ -0,0 +1,180 @@
1
+ import {
2
+ ToolResponse,
3
+ toJSONSchema,
4
+ type Tool,
5
+ type ToolModelContentPart,
6
+ } from "assistant-stream";
7
+ import { generateId } from "@assistant-ui/core/internal";
8
+ import type {
9
+ WebMcpCallToolResult,
10
+ WebMcpContent,
11
+ WebMcpToolDescriptor,
12
+ } from "./webmcp-host";
13
+
14
+ /**
15
+ * The predicate the WebMCP provider uses when no `filter` is passed: an enabled
16
+ * frontend tool with a client-side `execute`. A tool authored without a `type`
17
+ * is included, because `execute` is what distinguishes the deprecated
18
+ * type-less form from a backend or human tool. A `filter` replaces this, so
19
+ * pass it through to narrow the default set rather than widen it.
20
+ */
21
+ export const defaultWebMcpFilter = (
22
+ _name: string,
23
+ tool: Tool<any, any>,
24
+ ): boolean =>
25
+ (tool.type === "frontend" || tool.type === undefined) &&
26
+ !!tool.execute &&
27
+ !tool.disabled;
28
+
29
+ export const toWebMcpInputSchema = (tool: Tool<any, any>): unknown =>
30
+ tool.parameters
31
+ ? toJSONSchema(tool.parameters)
32
+ : { type: "object", properties: {} };
33
+
34
+ const textContent = (text: string): WebMcpContent => ({ type: "text", text });
35
+
36
+ const errorResult = (message: string): WebMcpCallToolResult => ({
37
+ isError: true,
38
+ content: [textContent(message)],
39
+ });
40
+
41
+ // bigint throws in JSON.stringify and symbol serializes to undefined, but both
42
+ // have a faithful string form. A value that cannot be serialized at all still
43
+ // throws through to the error result.
44
+ const toText = (value: unknown): string => {
45
+ if (typeof value === "string") return value;
46
+ if (typeof value === "bigint" || typeof value === "symbol") {
47
+ return value.toString();
48
+ }
49
+ return JSON.stringify(value) ?? String(value);
50
+ };
51
+
52
+ const primitiveContent = (value: unknown): WebMcpContent[] => [
53
+ textContent(toText(value)),
54
+ ];
55
+
56
+ const mapModelContentPart = (part: ToolModelContentPart): WebMcpContent => {
57
+ if (part.type === "text") {
58
+ return textContent(part.text ?? "");
59
+ }
60
+ if (part.type === "file") {
61
+ if (
62
+ typeof part.mediaType === "string" &&
63
+ part.mediaType.startsWith("image/")
64
+ ) {
65
+ return { type: "image", data: part.data ?? "", mimeType: part.mediaType };
66
+ }
67
+ return textContent(part.data ?? "");
68
+ }
69
+ return textContent(toText(part));
70
+ };
71
+
72
+ export const toMcpContent = async (
73
+ result: unknown,
74
+ options: {
75
+ tool: Tool<any, any>;
76
+ toolCallId: string;
77
+ args: Record<string, unknown>;
78
+ },
79
+ ): Promise<WebMcpCallToolResult> => {
80
+ const response = ToolResponse.toResponse(result);
81
+ if (response.modelContent) {
82
+ const content = response.modelContent.map(mapModelContentPart);
83
+ return response.isError ? { isError: true, content } : { content };
84
+ }
85
+ if (!response.isError && options.tool.toModelOutput) {
86
+ try {
87
+ const parts = await options.tool.toModelOutput({
88
+ toolCallId: options.toolCallId,
89
+ input: options.args,
90
+ output: response.result,
91
+ });
92
+ return { content: parts.map(mapModelContentPart) };
93
+ } catch (e) {
94
+ console.warn(
95
+ "[assistant-ui] toModelOutput threw; falling back to default projection.",
96
+ e,
97
+ );
98
+ }
99
+ }
100
+ const content = primitiveContent(response.result);
101
+ return response.isError ? { isError: true, content } : { content };
102
+ };
103
+
104
+ type StandardSchemaLike = {
105
+ "~standard": {
106
+ version: number;
107
+ validate: (
108
+ value: unknown,
109
+ ) =>
110
+ | { issues?: readonly unknown[] | undefined }
111
+ | Promise<{ issues?: readonly unknown[] | undefined }>;
112
+ };
113
+ };
114
+
115
+ const isStandardSchema = (schema: unknown): schema is StandardSchemaLike =>
116
+ typeof schema === "object" &&
117
+ schema !== null &&
118
+ "~standard" in schema &&
119
+ (schema as StandardSchemaLike)["~standard"].version === 1;
120
+
121
+ export const toWebMcpTool = (
122
+ name: string,
123
+ getTool: () => Tool<any, any>,
124
+ lifecycleSignal?: AbortSignal,
125
+ ): WebMcpToolDescriptor => ({
126
+ name,
127
+ description: getTool().description ?? "",
128
+ inputSchema: toWebMcpInputSchema(getTool()),
129
+ execute: async (rawArgs, context) => {
130
+ if (lifecycleSignal?.aborted) {
131
+ return errorResult(`Tool "${name}" is no longer registered`);
132
+ }
133
+ const tool = getTool();
134
+ const args = (rawArgs ?? {}) as Record<string, unknown>;
135
+ const toolCallId = generateId();
136
+ try {
137
+ const callerSignal = context?.signal;
138
+ const abortSignal = !callerSignal
139
+ ? lifecycleSignal
140
+ : !lifecycleSignal
141
+ ? callerSignal
142
+ : AbortSignal.any([callerSignal, lifecycleSignal]);
143
+ let executeFn = tool.execute;
144
+ if (isStandardSchema(tool.parameters)) {
145
+ let validation = tool.parameters["~standard"].validate(args);
146
+ validation = await validation;
147
+ if (validation.issues) {
148
+ const issues = validation.issues;
149
+ executeFn =
150
+ tool.experimental_onSchemaValidationError ??
151
+ (() => {
152
+ throw new Error(
153
+ `Function parameter validation failed. ${JSON.stringify(issues)}`,
154
+ );
155
+ });
156
+ }
157
+ }
158
+
159
+ if (abortSignal?.aborted) {
160
+ return errorResult("Tool execution was cancelled.");
161
+ }
162
+
163
+ if (!executeFn) {
164
+ return errorResult(`Tool "${name}" has no client-side implementation.`);
165
+ }
166
+
167
+ const result = await executeFn(args, {
168
+ toolCallId,
169
+ abortSignal: abortSignal ?? new AbortController().signal,
170
+ human: () =>
171
+ Promise.reject(
172
+ new Error("human input not supported in WebMCP context"),
173
+ ),
174
+ });
175
+ return await toMcpContent(result, { tool, toolCallId, args });
176
+ } catch (e) {
177
+ return errorResult(e instanceof Error ? e.message : String(e));
178
+ }
179
+ },
180
+ });