@assistant-ui/ai-sdk 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +32 -0
  3. package/dist/adapters/aiSDKFormatAdapter.d.ts +8 -0
  4. package/dist/adapters/aiSDKFormatAdapter.d.ts.map +1 -0
  5. package/dist/adapters/aiSDKFormatAdapter.js +26 -0
  6. package/dist/adapters/aiSDKFormatAdapter.js.map +1 -0
  7. package/dist/adapters/vercelAttachmentAdapter.d.ts +6 -0
  8. package/dist/adapters/vercelAttachmentAdapter.d.ts.map +1 -0
  9. package/dist/adapters/vercelAttachmentAdapter.js +37 -0
  10. package/dist/adapters/vercelAttachmentAdapter.js.map +1 -0
  11. package/dist/aiSDKExtras.d.ts +11 -0
  12. package/dist/aiSDKExtras.d.ts.map +1 -0
  13. package/dist/aiSDKExtras.js +7 -0
  14. package/dist/aiSDKExtras.js.map +1 -0
  15. package/dist/converters/convertMessage.d.ts +28 -0
  16. package/dist/converters/convertMessage.d.ts.map +1 -0
  17. package/dist/converters/convertMessage.js +249 -0
  18. package/dist/converters/convertMessage.js.map +1 -0
  19. package/dist/converters/modelContentEnvelope.d.ts +16 -0
  20. package/dist/converters/modelContentEnvelope.d.ts.map +1 -0
  21. package/dist/converters/modelContentEnvelope.js +22 -0
  22. package/dist/converters/modelContentEnvelope.js.map +1 -0
  23. package/dist/converters/toCreateMessage.d.ts +7 -0
  24. package/dist/converters/toCreateMessage.d.ts.map +1 -0
  25. package/dist/converters/toCreateMessage.js +58 -0
  26. package/dist/converters/toCreateMessage.js.map +1 -0
  27. package/dist/converters/toolOutputConversion.d.ts +29 -0
  28. package/dist/converters/toolOutputConversion.d.ts.map +1 -0
  29. package/dist/converters/toolOutputConversion.js +31 -0
  30. package/dist/converters/toolOutputConversion.js.map +1 -0
  31. package/dist/hooks.d.ts +19 -0
  32. package/dist/hooks.d.ts.map +1 -0
  33. package/dist/hooks.js +20 -0
  34. package/dist/hooks.js.map +1 -0
  35. package/dist/index.d.ts +14 -0
  36. package/dist/index.js +13 -0
  37. package/dist/index.native.d.ts +12 -0
  38. package/dist/index.native.js +11 -0
  39. package/dist/model-context/injectInteractableContext.d.ts +37 -0
  40. package/dist/model-context/injectInteractableContext.d.ts.map +1 -0
  41. package/dist/model-context/injectInteractableContext.js +52 -0
  42. package/dist/model-context/injectInteractableContext.js.map +1 -0
  43. package/dist/model-context/injectQuoteContext.d.ts +27 -0
  44. package/dist/model-context/injectQuoteContext.d.ts.map +1 -0
  45. package/dist/model-context/injectQuoteContext.js +51 -0
  46. package/dist/model-context/injectQuoteContext.js.map +1 -0
  47. package/dist/runtime/AISDKChat.d.ts +16 -0
  48. package/dist/runtime/AISDKChat.d.ts.map +1 -0
  49. package/dist/runtime/AISDKChat.js +69 -0
  50. package/dist/runtime/AISDKChat.js.map +1 -0
  51. package/dist/runtime/AISDKThreads.d.ts +45 -0
  52. package/dist/runtime/AISDKThreads.d.ts.map +1 -0
  53. package/dist/runtime/AISDKThreads.js +203 -0
  54. package/dist/runtime/AISDKThreads.js.map +1 -0
  55. package/dist/runtime/useAISDKRuntime.d.ts +49 -0
  56. package/dist/runtime/useAISDKRuntime.d.ts.map +1 -0
  57. package/dist/runtime/useAISDKRuntime.js +266 -0
  58. package/dist/runtime/useAISDKRuntime.js.map +1 -0
  59. package/dist/runtime/useChatRuntime.d.ts +13 -0
  60. package/dist/runtime/useChatRuntime.d.ts.map +1 -0
  61. package/dist/runtime/useChatRuntime.js +52 -0
  62. package/dist/runtime/useChatRuntime.js.map +1 -0
  63. package/dist/runtime/useChatThread.d.ts +77 -0
  64. package/dist/runtime/useChatThread.d.ts.map +1 -0
  65. package/dist/runtime/useChatThread.js +134 -0
  66. package/dist/runtime/useChatThread.js.map +1 -0
  67. package/dist/runtime/useExternalHistory.d.ts +11 -0
  68. package/dist/runtime/useExternalHistory.d.ts.map +1 -0
  69. package/dist/runtime/useExternalHistory.js +278 -0
  70. package/dist/runtime/useExternalHistory.js.map +1 -0
  71. package/dist/runtime/useResourceCleanup.d.ts +5 -0
  72. package/dist/runtime/useResourceCleanup.d.ts.map +1 -0
  73. package/dist/runtime/useResourceCleanup.js +47 -0
  74. package/dist/runtime/useResourceCleanup.js.map +1 -0
  75. package/dist/runtime/useStreamingTiming.d.ts +15 -0
  76. package/dist/runtime/useStreamingTiming.d.ts.map +1 -0
  77. package/dist/runtime/useStreamingTiming.js +38 -0
  78. package/dist/runtime/useStreamingTiming.js.map +1 -0
  79. package/dist/tools/frontendTools.d.ts +33 -0
  80. package/dist/tools/frontendTools.d.ts.map +1 -0
  81. package/dist/tools/frontendTools.js +33 -0
  82. package/dist/tools/frontendTools.js.map +1 -0
  83. package/dist/tools/generativeTools.d.ts +73 -0
  84. package/dist/tools/generativeTools.d.ts.map +1 -0
  85. package/dist/tools/generativeTools.js +245 -0
  86. package/dist/tools/generativeTools.js.map +1 -0
  87. package/dist/tools/mcp-stdio.node.d.ts +2 -0
  88. package/dist/tools/mcp-stdio.node.js +2 -0
  89. package/dist/tools/mcp-stdio.unsupported.d.ts +6 -0
  90. package/dist/tools/mcp-stdio.unsupported.d.ts.map +1 -0
  91. package/dist/tools/mcp-stdio.unsupported.js +11 -0
  92. package/dist/tools/mcp-stdio.unsupported.js.map +1 -0
  93. package/dist/transport/AssistantChatTransport.d.ts +23 -0
  94. package/dist/transport/AssistantChatTransport.d.ts.map +1 -0
  95. package/dist/transport/AssistantChatTransport.js +134 -0
  96. package/dist/transport/AssistantChatTransport.js.map +1 -0
  97. package/dist/transport/resumable.d.ts +38 -0
  98. package/dist/transport/resumable.d.ts.map +1 -0
  99. package/dist/transport/resumable.js +113 -0
  100. package/dist/transport/resumable.js.map +1 -0
  101. package/dist/usage.d.ts +19 -0
  102. package/dist/usage.d.ts.map +1 -0
  103. package/dist/usage.js +127 -0
  104. package/dist/usage.js.map +1 -0
  105. package/dist/utils/getVercelAIMessages.d.ts +7 -0
  106. package/dist/utils/getVercelAIMessages.d.ts.map +1 -0
  107. package/dist/utils/getVercelAIMessages.js +9 -0
  108. package/dist/utils/getVercelAIMessages.js.map +1 -0
  109. package/dist/utils/sliceMessagesUntil.d.ts +6 -0
  110. package/dist/utils/sliceMessagesUntil.d.ts.map +1 -0
  111. package/dist/utils/sliceMessagesUntil.js +12 -0
  112. package/dist/utils/sliceMessagesUntil.js.map +1 -0
  113. package/package.json +100 -0
  114. package/src/__tests__/standalone.e2e.ts +59 -0
  115. package/src/adapters/aiSDKFormatAdapter.ts +41 -0
  116. package/src/adapters/vercelAttachmentAdapter.test.ts +29 -0
  117. package/src/adapters/vercelAttachmentAdapter.ts +36 -0
  118. package/src/aiSDKExtras.ts +11 -0
  119. package/src/converters/convertMessage.test.ts +1130 -0
  120. package/src/converters/convertMessage.ts +457 -0
  121. package/src/converters/modelContentEnvelope.ts +41 -0
  122. package/src/converters/toCreateMessage.test.ts +596 -0
  123. package/src/converters/toCreateMessage.ts +99 -0
  124. package/src/converters/toolOutputConversion.ts +26 -0
  125. package/src/hooks.ts +23 -0
  126. package/src/index.native.ts +21 -0
  127. package/src/index.ts +29 -0
  128. package/src/model-context/injectInteractableContext.test.ts +167 -0
  129. package/src/model-context/injectInteractableContext.ts +61 -0
  130. package/src/model-context/injectQuoteContext.test.ts +33 -0
  131. package/src/model-context/injectQuoteContext.ts +62 -0
  132. package/src/runtime/AISDKChat.integration.test.tsx +47 -0
  133. package/src/runtime/AISDKChat.react-compiler.test.ts +49 -0
  134. package/src/runtime/AISDKChat.test.ts +193 -0
  135. package/src/runtime/AISDKChat.ts +47 -0
  136. package/src/runtime/AISDKThreads.cloud.test.ts +136 -0
  137. package/src/runtime/AISDKThreads.test.ts +548 -0
  138. package/src/runtime/AISDKThreads.ts +223 -0
  139. package/src/runtime/__tests__/controlled-transport.ts +43 -0
  140. package/src/runtime/useAISDKRuntime.approval.test.tsx +69 -0
  141. package/src/runtime/useAISDKRuntime.test.ts +1021 -0
  142. package/src/runtime/useAISDKRuntime.tool-output.test.tsx +113 -0
  143. package/src/runtime/useAISDKRuntime.ts +524 -0
  144. package/src/runtime/useChatOnTap.test.ts +58 -0
  145. package/src/runtime/useChatRuntime.integration.test.tsx +67 -0
  146. package/src/runtime/useChatRuntime.test.ts +522 -0
  147. package/src/runtime/useChatRuntime.ts +49 -0
  148. package/src/runtime/useChatThread.ts +286 -0
  149. package/src/runtime/useExternalHistory.test.ts +1289 -0
  150. package/src/runtime/useExternalHistory.ts +435 -0
  151. package/src/runtime/useResourceCleanup.ts +32 -0
  152. package/src/runtime/useStreamingTiming.ts +68 -0
  153. package/src/tools/frontendTools.test.ts +205 -0
  154. package/src/tools/frontendTools.ts +83 -0
  155. package/src/tools/generativeTools.test.ts +785 -0
  156. package/src/tools/generativeTools.ts +470 -0
  157. package/src/tools/mcp-stdio.node.ts +1 -0
  158. package/src/tools/mcp-stdio.unsupported.ts +12 -0
  159. package/src/transport/AssistantChatTransport.test.ts +300 -0
  160. package/src/transport/AssistantChatTransport.ts +204 -0
  161. package/src/transport/resumable.test.ts +262 -0
  162. package/src/transport/resumable.ts +161 -0
  163. package/src/usage.test.ts +186 -0
  164. package/src/usage.ts +174 -0
  165. package/src/utils/getVercelAIMessages.ts +11 -0
  166. package/src/utils/sliceMessagesUntil.test.ts +55 -0
  167. package/src/utils/sliceMessagesUntil.ts +20 -0
@@ -0,0 +1,785 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { defineMcpToolkit } from "@assistant-ui/core/react";
3
+ import { AISDKToolkit } from "./generativeTools";
4
+ import { wrapModelContentEnvelope } from "../converters/modelContentEnvelope";
5
+
6
+ const mocks = vi.hoisted(() => ({
7
+ close: vi.fn(),
8
+ tools: vi.fn(),
9
+ createMCPClient: vi.fn(),
10
+ }));
11
+
12
+ vi.mock("@ai-sdk/mcp", () => ({
13
+ createMCPClient: mocks.createMCPClient,
14
+ }));
15
+
16
+ vi.mock("@ai-sdk/mcp/mcp-stdio", () => ({
17
+ Experimental_StdioMCPTransport: vi.fn((config) => ({
18
+ type: "stdio",
19
+ config,
20
+ })),
21
+ }));
22
+
23
+ const never = <T>() => new Promise<T>(() => {});
24
+
25
+ describe("AISDKToolkit.tools()", () => {
26
+ it("merges frontend tools with toolkit tools", async () => {
27
+ const toolSet = await new AISDKToolkit({
28
+ toolkit: {
29
+ serverTool: {
30
+ type: "backend",
31
+ description: "Server tool",
32
+ parameters: { type: "object", properties: {} },
33
+ execute: async () => "ok",
34
+ } as never,
35
+ },
36
+ }).tools({
37
+ frontend: {
38
+ clientTool: {
39
+ parameters: { type: "object", properties: {} },
40
+ },
41
+ },
42
+ });
43
+
44
+ expect(toolSet.clientTool).toBeDefined();
45
+ expect(toolSet.serverTool?.description).toBe("Server tool");
46
+ expect(toolSet.serverTool?.execute).toBeTypeOf("function");
47
+ });
48
+
49
+ it("keeps a flat toolkit tool named tools", async () => {
50
+ const toolSet = await new AISDKToolkit({
51
+ toolkit: {
52
+ tools: {
53
+ type: "backend",
54
+ description: "Actually a tool, not config",
55
+ parameters: { type: "object", properties: {} },
56
+ execute: async () => "ok",
57
+ } as never,
58
+ },
59
+ }).tools();
60
+
61
+ expect(toolSet.tools?.description).toBe("Actually a tool, not config");
62
+ expect(toolSet.tools?.execute).toBeTypeOf("function");
63
+ });
64
+
65
+ it("converts provider tools without an execute function", async () => {
66
+ const toolSet = await new AISDKToolkit({
67
+ toolkit: {
68
+ web_search: {
69
+ type: "provider",
70
+ providerId: "openai.web_search_preview",
71
+ args: { searchContextSize: "low" },
72
+ },
73
+ },
74
+ }).tools();
75
+
76
+ expect(toolSet.web_search).toMatchObject({
77
+ type: "provider",
78
+ id: "openai.web_search_preview",
79
+ args: { searchContextSize: "low" },
80
+ });
81
+ expect(toolSet.web_search).not.toHaveProperty("inputSchema");
82
+ expect(toolSet.web_search).not.toHaveProperty("execute");
83
+ });
84
+
85
+ it("forwards provider tool parameters and providerOptions when present", async () => {
86
+ const toolSet = await new AISDKToolkit({
87
+ toolkit: {
88
+ web_search: {
89
+ type: "provider",
90
+ providerId: "openai.web_search_preview",
91
+ args: { searchContextSize: "low" },
92
+ parameters: {
93
+ type: "object",
94
+ properties: {
95
+ query: { type: "string" },
96
+ },
97
+ required: ["query"],
98
+ },
99
+ providerOptions: {
100
+ openai: { rankingOptions: { scoreThreshold: 0.5 } },
101
+ },
102
+ },
103
+ },
104
+ }).tools();
105
+
106
+ expect(toolSet.web_search).toMatchObject({
107
+ type: "provider",
108
+ id: "openai.web_search_preview",
109
+ args: { searchContextSize: "low" },
110
+ providerOptions: {
111
+ openai: { rankingOptions: { scoreThreshold: 0.5 } },
112
+ },
113
+ });
114
+ expect(toolSet.web_search).toHaveProperty("inputSchema");
115
+ });
116
+
117
+ it("forwards explicit false supportsDeferredResults", async () => {
118
+ const toolSet = await new AISDKToolkit({
119
+ toolkit: {
120
+ web_search: {
121
+ type: "provider",
122
+ providerId: "openai.web_search_preview",
123
+ args: { searchContextSize: "low" },
124
+ supportsDeferredResults: false,
125
+ },
126
+ },
127
+ }).tools();
128
+
129
+ expect(toolSet.web_search).toMatchObject({
130
+ supportsDeferredResults: false,
131
+ });
132
+ });
133
+ });
134
+
135
+ describe("AISDKToolkit", () => {
136
+ beforeEach(() => {
137
+ mocks.close.mockReset();
138
+ mocks.tools.mockReset();
139
+ mocks.createMCPClient.mockReset();
140
+ });
141
+
142
+ it("loads MCP tools through pooled clients", async () => {
143
+ mocks.tools.mockResolvedValue({ echo: { inputSchema: {} } });
144
+ mocks.createMCPClient.mockResolvedValue({
145
+ tools: mocks.tools,
146
+ close: mocks.close,
147
+ });
148
+
149
+ const toolkit = new AISDKToolkit({
150
+ toolkit: {
151
+ local: {
152
+ type: "mcp",
153
+ server: { type: "http", url: "http://localhost:3001/mcp" },
154
+ },
155
+ },
156
+ });
157
+
158
+ await expect(toolkit.tools()).resolves.toHaveProperty("echo");
159
+ await toolkit.tools();
160
+
161
+ expect(mocks.createMCPClient).toHaveBeenCalledTimes(1);
162
+ expect(mocks.createMCPClient).toHaveBeenCalledWith({
163
+ transport: {
164
+ type: "http",
165
+ url: "http://localhost:3001/mcp",
166
+ },
167
+ });
168
+ expect(mocks.tools).toHaveBeenCalledTimes(2);
169
+ });
170
+
171
+ it("does not connect disabled MCP toolkit entries", async () => {
172
+ const toolkit = new AISDKToolkit({
173
+ toolkit: {
174
+ local: {
175
+ type: "mcp",
176
+ server: { type: "http", url: "http://localhost:3001/mcp" },
177
+ disabled: true,
178
+ },
179
+ },
180
+ });
181
+
182
+ await expect(toolkit.tools()).resolves.toEqual({});
183
+
184
+ expect(mocks.createMCPClient).not.toHaveBeenCalled();
185
+ expect(mocks.tools).not.toHaveBeenCalled();
186
+ });
187
+
188
+ it("filters disabled MCP tools from enabled toolkit entries", async () => {
189
+ mocks.tools.mockResolvedValue({
190
+ publicSearch: { inputSchema: {} },
191
+ privateSearch: { inputSchema: {} },
192
+ });
193
+ mocks.createMCPClient.mockResolvedValue({
194
+ tools: mocks.tools,
195
+ close: mocks.close,
196
+ });
197
+
198
+ const toolkit = new AISDKToolkit({
199
+ toolkit: {
200
+ local: {
201
+ type: "mcp",
202
+ server: { type: "http", url: "http://localhost:3001/mcp" },
203
+ tools: {
204
+ privateSearch: {
205
+ disabled: true,
206
+ },
207
+ },
208
+ } as never,
209
+ },
210
+ });
211
+
212
+ await expect(toolkit.tools()).resolves.toEqual({
213
+ publicSearch: { inputSchema: {} },
214
+ });
215
+
216
+ expect(mocks.createMCPClient).toHaveBeenCalledTimes(1);
217
+ expect(mocks.tools).toHaveBeenCalledTimes(1);
218
+ });
219
+
220
+ it("closes pooled MCP clients", async () => {
221
+ mocks.tools.mockResolvedValue({});
222
+ mocks.createMCPClient.mockResolvedValue({
223
+ tools: mocks.tools,
224
+ close: mocks.close,
225
+ });
226
+
227
+ const toolkit = new AISDKToolkit({
228
+ toolkit: {
229
+ local: {
230
+ type: "mcp",
231
+ server: { type: "sse", url: "http://localhost:3001/sse" },
232
+ },
233
+ },
234
+ });
235
+
236
+ await toolkit.tools();
237
+ await toolkit.close();
238
+
239
+ expect(mocks.close).toHaveBeenCalledTimes(1);
240
+ });
241
+
242
+ it("clears pooled MCP clients even when initialization fails", async () => {
243
+ const error = new Error("connect failed");
244
+ const closeError = new Error("close failed");
245
+ const close = vi.fn().mockRejectedValue(closeError);
246
+ mocks.tools.mockResolvedValue({});
247
+ mocks.createMCPClient
248
+ .mockResolvedValueOnce({
249
+ tools: mocks.tools,
250
+ close,
251
+ })
252
+ .mockRejectedValueOnce(error);
253
+
254
+ const toolkit = new AISDKToolkit({
255
+ toolkit: {
256
+ first: {
257
+ type: "mcp",
258
+ server: { type: "http", url: "http://localhost:3001/mcp" },
259
+ },
260
+ second: {
261
+ type: "mcp",
262
+ server: { type: "http", url: "http://localhost:3002/mcp" },
263
+ },
264
+ },
265
+ });
266
+
267
+ const toolsPromise = toolkit.tools();
268
+ await expect(toolkit.close()).rejects.toMatchObject({
269
+ errors: [
270
+ {
271
+ message:
272
+ 'MCP toolkit entry "second" failed to connect: connect failed',
273
+ cause: error,
274
+ },
275
+ {
276
+ message: 'MCP toolkit entry "first" failed to close: close failed',
277
+ cause: closeError,
278
+ },
279
+ ],
280
+ });
281
+ await expect(toolsPromise).rejects.toMatchObject({
282
+ message: 'MCP toolkit entry "second" failed to connect: connect failed',
283
+ cause: error,
284
+ });
285
+ expect(close).toHaveBeenCalledTimes(1);
286
+
287
+ await expect(toolkit.close()).resolves.toBeUndefined();
288
+ });
289
+
290
+ it("includes the MCP toolkit entry name when closing a client fails", async () => {
291
+ const closeError = new Error("close failed");
292
+ mocks.tools.mockResolvedValue({});
293
+ mocks.createMCPClient.mockResolvedValue({
294
+ tools: mocks.tools,
295
+ close: vi.fn().mockRejectedValue(closeError),
296
+ });
297
+
298
+ const toolkit = new AISDKToolkit({
299
+ toolkit: {
300
+ github: {
301
+ type: "mcp",
302
+ server: { type: "http", url: "http://localhost:3001/mcp" },
303
+ },
304
+ },
305
+ });
306
+
307
+ await toolkit.tools();
308
+
309
+ await expect(toolkit.close()).rejects.toMatchObject({
310
+ message: 'MCP toolkit entry "github" failed to close: close failed',
311
+ cause: closeError,
312
+ });
313
+ });
314
+
315
+ it("evicts failed MCP client initialization so later calls can retry", async () => {
316
+ const error = new Error("connect failed");
317
+ mocks.createMCPClient.mockRejectedValueOnce(error).mockResolvedValueOnce({
318
+ tools: vi.fn().mockResolvedValue({ echo: { inputSchema: {} } }),
319
+ close: mocks.close,
320
+ });
321
+
322
+ const toolkit = new AISDKToolkit({
323
+ toolkit: {
324
+ local: {
325
+ type: "mcp",
326
+ server: { type: "http", url: "http://localhost:3001/mcp" },
327
+ },
328
+ },
329
+ });
330
+
331
+ await expect(toolkit.tools()).rejects.toMatchObject({
332
+ message: 'MCP toolkit entry "local" failed to connect: connect failed',
333
+ cause: error,
334
+ });
335
+ await expect(toolkit.tools()).resolves.toHaveProperty("echo");
336
+ expect(mocks.createMCPClient).toHaveBeenCalledTimes(2);
337
+ });
338
+
339
+ it("times out MCP client creation", async () => {
340
+ vi.useFakeTimers();
341
+ mocks.createMCPClient.mockReturnValue(never());
342
+
343
+ const toolkit = new AISDKToolkit({
344
+ toolkit: {
345
+ docs: {
346
+ type: "mcp",
347
+ server: {
348
+ type: "http",
349
+ url: "http://localhost:3001/mcp",
350
+ connectionTimeout: 10_000,
351
+ },
352
+ },
353
+ },
354
+ });
355
+
356
+ try {
357
+ const toolsPromise = toolkit.tools();
358
+ const expectedRejection = expect(toolsPromise).rejects.toThrow(
359
+ 'MCP toolkit entry "docs" timed out while connecting after 10000ms.',
360
+ );
361
+ await vi.advanceTimersByTimeAsync(10_000);
362
+ await expectedRejection;
363
+
364
+ mocks.createMCPClient.mockResolvedValueOnce({
365
+ tools: vi.fn().mockResolvedValue({ echo: { inputSchema: {} } }),
366
+ close: mocks.close,
367
+ });
368
+ await expect(toolkit.tools()).resolves.toHaveProperty("echo");
369
+ expect(mocks.createMCPClient).toHaveBeenCalledTimes(2);
370
+ } finally {
371
+ vi.useRealTimers();
372
+ }
373
+ });
374
+
375
+ it("closes an MCP client that resolves after its connection times out", async () => {
376
+ vi.useFakeTimers();
377
+ const close = vi.fn().mockResolvedValue(undefined);
378
+ let resolveClient!: (client: {
379
+ tools: typeof mocks.tools;
380
+ close: typeof close;
381
+ }) => void;
382
+ mocks.createMCPClient.mockReturnValue(
383
+ new Promise((resolve) => {
384
+ resolveClient = resolve;
385
+ }),
386
+ );
387
+
388
+ const toolkit = new AISDKToolkit({
389
+ toolkit: {
390
+ docs: {
391
+ type: "mcp",
392
+ server: {
393
+ type: "http",
394
+ url: "http://localhost:3001/mcp",
395
+ connectionTimeout: 10_000,
396
+ },
397
+ },
398
+ },
399
+ });
400
+
401
+ try {
402
+ const toolsPromise = toolkit.tools();
403
+ const expectedRejection = expect(toolsPromise).rejects.toThrow(
404
+ 'MCP toolkit entry "docs" timed out while connecting after 10000ms.',
405
+ );
406
+ await vi.advanceTimersByTimeAsync(10_000);
407
+ await expectedRejection;
408
+
409
+ resolveClient({ tools: mocks.tools, close });
410
+ await vi.waitFor(() => expect(close).toHaveBeenCalledTimes(1));
411
+ } finally {
412
+ vi.useRealTimers();
413
+ }
414
+ });
415
+
416
+ it("times out MCP tool listing", async () => {
417
+ vi.useFakeTimers();
418
+ const close = vi.fn().mockResolvedValue(undefined);
419
+ mocks.createMCPClient.mockResolvedValue({
420
+ tools: vi.fn(() => never()),
421
+ close,
422
+ });
423
+
424
+ const toolkit = new AISDKToolkit({
425
+ toolkit: {
426
+ docs: {
427
+ type: "mcp",
428
+ server: {
429
+ type: "http",
430
+ url: "http://localhost:3001/mcp",
431
+ connectionTimeout: 10_000,
432
+ },
433
+ },
434
+ },
435
+ });
436
+
437
+ try {
438
+ const toolsPromise = toolkit.tools();
439
+ const expectedRejection = expect(toolsPromise).rejects.toThrow(
440
+ 'MCP toolkit entry "docs" timed out while listing tools after 10000ms.',
441
+ );
442
+ await vi.advanceTimersByTimeAsync(10_000);
443
+ await expectedRejection;
444
+ expect(close).toHaveBeenCalledTimes(1);
445
+
446
+ mocks.createMCPClient.mockResolvedValueOnce({
447
+ tools: vi.fn().mockResolvedValue({ echo: { inputSchema: {} } }),
448
+ close: mocks.close,
449
+ });
450
+ await expect(toolkit.tools()).resolves.toHaveProperty("echo");
451
+ expect(mocks.createMCPClient).toHaveBeenCalledTimes(2);
452
+ } finally {
453
+ vi.useRealTimers();
454
+ }
455
+ });
456
+
457
+ it("includes the MCP toolkit entry name when listing tools fails", async () => {
458
+ const error = new Error("list failed");
459
+ mocks.tools.mockRejectedValue(error);
460
+ mocks.createMCPClient.mockResolvedValue({
461
+ tools: mocks.tools,
462
+ close: mocks.close,
463
+ });
464
+
465
+ const toolkit = new AISDKToolkit({
466
+ toolkit: {
467
+ docs: {
468
+ type: "mcp",
469
+ server: { type: "http", url: "http://localhost:3001/mcp" },
470
+ },
471
+ },
472
+ });
473
+
474
+ await expect(toolkit.tools()).rejects.toMatchObject({
475
+ message: 'MCP toolkit entry "docs" failed to list tools: list failed',
476
+ cause: error,
477
+ });
478
+ });
479
+
480
+ it("rejects duplicate MCP tool names", async () => {
481
+ mocks.createMCPClient
482
+ .mockResolvedValueOnce({
483
+ tools: vi.fn().mockResolvedValue({ echo: { inputSchema: {} } }),
484
+ close: mocks.close,
485
+ })
486
+ .mockResolvedValueOnce({
487
+ tools: vi.fn().mockResolvedValue({ echo: { inputSchema: {} } }),
488
+ close: mocks.close,
489
+ });
490
+
491
+ const toolkit = new AISDKToolkit({
492
+ toolkit: {
493
+ first: {
494
+ type: "mcp",
495
+ server: { type: "http", url: "http://localhost:3001/mcp" },
496
+ },
497
+ second: {
498
+ type: "mcp",
499
+ server: { type: "http", url: "http://localhost:3002/mcp" },
500
+ },
501
+ },
502
+ });
503
+
504
+ await expect(toolkit.tools()).rejects.toThrow(
505
+ /MCP tool name collision: "echo"/,
506
+ );
507
+ });
508
+
509
+ it("prefixes MCP tool names when configured", async () => {
510
+ const docsExecute = vi.fn().mockResolvedValue("docs result");
511
+ mocks.createMCPClient
512
+ .mockResolvedValueOnce({
513
+ tools: vi.fn().mockResolvedValue({
514
+ search: { inputSchema: {}, execute: docsExecute },
515
+ }),
516
+ close: mocks.close,
517
+ })
518
+ .mockResolvedValueOnce({
519
+ tools: vi.fn().mockResolvedValue({ search: { inputSchema: {} } }),
520
+ close: mocks.close,
521
+ });
522
+
523
+ const toolkit = new AISDKToolkit({
524
+ toolkit: defineMcpToolkit({
525
+ docs: {
526
+ server: { type: "http", url: "http://localhost:3001/mcp" },
527
+ prefix: "docs_",
528
+ },
529
+ github: {
530
+ server: { type: "http", url: "http://localhost:3002/mcp" },
531
+ prefix: "github_",
532
+ },
533
+ }),
534
+ });
535
+
536
+ const toolSet = await toolkit.tools();
537
+
538
+ expect(toolSet).toHaveProperty("docs_search");
539
+ expect(toolSet).toHaveProperty("github_search");
540
+ expect(toolSet).not.toHaveProperty("search");
541
+
542
+ expect(toolSet.docs_search?.execute).toBeTypeOf("function");
543
+ const executeOptions = {
544
+ toolCallId: "call-docs-search",
545
+ messages: [],
546
+ };
547
+ await expect(
548
+ toolSet.docs_search?.execute?.({ query: "assistant-ui" }, executeOptions),
549
+ ).resolves.toBe("docs result");
550
+ expect(docsExecute).toHaveBeenCalledWith(
551
+ { query: "assistant-ui" },
552
+ executeOptions,
553
+ );
554
+ });
555
+
556
+ it("rejects MCP tool names that collide with toolkit tools", async () => {
557
+ mocks.tools.mockResolvedValue({ search: { inputSchema: {} } });
558
+ mocks.createMCPClient.mockResolvedValue({
559
+ tools: mocks.tools,
560
+ close: mocks.close,
561
+ });
562
+
563
+ const toolkit = new AISDKToolkit({
564
+ toolkit: {
565
+ docs: {
566
+ type: "mcp",
567
+ server: { type: "http", url: "http://localhost:3001/mcp" },
568
+ },
569
+ search: {
570
+ type: "backend",
571
+ parameters: { type: "object", properties: {} },
572
+ execute: async () => "local search",
573
+ } as never,
574
+ },
575
+ });
576
+
577
+ await expect(toolkit.tools()).rejects.toThrow(
578
+ 'MCP tool "search" from "docs" conflicts with toolkit tool "search"',
579
+ );
580
+ });
581
+
582
+ it("rejects MCP tool names that collide with provider tools", async () => {
583
+ mocks.tools.mockResolvedValue({ web_search: { inputSchema: {} } });
584
+ mocks.createMCPClient.mockResolvedValue({
585
+ tools: mocks.tools,
586
+ close: mocks.close,
587
+ });
588
+
589
+ const toolkit = new AISDKToolkit({
590
+ toolkit: {
591
+ docs: {
592
+ type: "mcp",
593
+ server: { type: "http", url: "http://localhost:3001/mcp" },
594
+ },
595
+ web_search: {
596
+ type: "provider",
597
+ providerId: "openai.web_search_preview",
598
+ args: { searchContextSize: "low" },
599
+ },
600
+ },
601
+ });
602
+
603
+ await expect(toolkit.tools()).rejects.toThrow(
604
+ 'MCP tool "web_search" from "docs" conflicts with provider tool "web_search"',
605
+ );
606
+ });
607
+
608
+ it("rejects MCP tool names that collide with uploaded frontend tools", async () => {
609
+ mocks.tools.mockResolvedValue({ clientTool: { inputSchema: {} } });
610
+ mocks.createMCPClient.mockResolvedValue({
611
+ tools: mocks.tools,
612
+ close: mocks.close,
613
+ });
614
+
615
+ const toolkit = new AISDKToolkit({
616
+ toolkit: {
617
+ docs: {
618
+ type: "mcp",
619
+ server: { type: "http", url: "http://localhost:3001/mcp" },
620
+ },
621
+ },
622
+ });
623
+
624
+ await expect(
625
+ toolkit.tools({
626
+ frontend: {
627
+ clientTool: {
628
+ parameters: { type: "object", properties: {} },
629
+ },
630
+ },
631
+ }),
632
+ ).rejects.toThrow(
633
+ 'MCP tool "clientTool" from "docs" conflicts with frontend tool "clientTool"',
634
+ );
635
+ });
636
+
637
+ it("ignores disabled MCP tools during name collision checks", async () => {
638
+ mocks.createMCPClient
639
+ .mockResolvedValueOnce({
640
+ tools: vi.fn().mockResolvedValue({ echo: { inputSchema: {} } }),
641
+ close: mocks.close,
642
+ })
643
+ .mockResolvedValueOnce({
644
+ tools: vi.fn().mockResolvedValue({ echo: { inputSchema: {} } }),
645
+ close: mocks.close,
646
+ });
647
+
648
+ const toolkit = new AISDKToolkit({
649
+ toolkit: {
650
+ first: {
651
+ type: "mcp",
652
+ server: { type: "http", url: "http://localhost:3001/mcp" },
653
+ tools: {
654
+ echo: {
655
+ disabled: true,
656
+ },
657
+ },
658
+ } as never,
659
+ second: {
660
+ type: "mcp",
661
+ server: { type: "http", url: "http://localhost:3002/mcp" },
662
+ },
663
+ },
664
+ });
665
+
666
+ await expect(toolkit.tools()).resolves.toEqual({
667
+ echo: { inputSchema: {} },
668
+ });
669
+ });
670
+
671
+ it("includes provider tools alongside MCP tools", async () => {
672
+ mocks.tools.mockResolvedValue({ echo: { inputSchema: {} } });
673
+ mocks.createMCPClient.mockResolvedValue({
674
+ tools: mocks.tools,
675
+ close: mocks.close,
676
+ });
677
+
678
+ const toolkit = new AISDKToolkit({
679
+ toolkit: {
680
+ local: {
681
+ type: "mcp",
682
+ server: { type: "http", url: "http://localhost:3001/mcp" },
683
+ },
684
+ web_search: {
685
+ type: "provider",
686
+ providerId: "openai.web_search_preview",
687
+ args: { searchContextSize: "low" },
688
+ supportsDeferredResults: false,
689
+ },
690
+ },
691
+ });
692
+
693
+ await expect(toolkit.tools()).resolves.toMatchObject({
694
+ echo: { inputSchema: {} },
695
+ web_search: {
696
+ type: "provider",
697
+ id: "openai.web_search_preview",
698
+ args: { searchContextSize: "low" },
699
+ supportsDeferredResults: false,
700
+ },
701
+ });
702
+ });
703
+ });
704
+
705
+ describe("AISDKToolkit toModelOutput", () => {
706
+ const createWeatherTools = (toModelOutput?: any) =>
707
+ new AISDKToolkit({
708
+ toolkit: {
709
+ get_weather: {
710
+ ...(toModelOutput && { toModelOutput }),
711
+ },
712
+ } as any,
713
+ }).tools();
714
+
715
+ it("adapts assistant-ui model content parts to the AI SDK tool output shape", async () => {
716
+ const tools = await createWeatherTools(({ output }: any) => [
717
+ { type: "text", text: `Weather card displayed: ${output.location}` },
718
+ ]);
719
+
720
+ const output = await tools.get_weather!.toModelOutput!({
721
+ toolCallId: "tc-weather",
722
+ input: {},
723
+ output: { location: "San Francisco" },
724
+ });
725
+
726
+ expect(output).toEqual({
727
+ type: "content",
728
+ value: [{ type: "text", text: "Weather card displayed: San Francisco" }],
729
+ });
730
+ });
731
+
732
+ it("uses stored model content envelopes without re-running the custom projector", async () => {
733
+ let called = false;
734
+ const tools = await createWeatherTools(() => {
735
+ called = true;
736
+ return [{ type: "text", text: "recomputed" }];
737
+ });
738
+
739
+ const output = await tools.get_weather!.toModelOutput!({
740
+ toolCallId: "tc-weather",
741
+ input: {},
742
+ output: wrapModelContentEnvelope({ location: "San Francisco" }, [
743
+ { type: "text", text: "cached weather receipt" },
744
+ ]),
745
+ });
746
+
747
+ expect(called).toBe(false);
748
+ expect(output).toEqual({
749
+ type: "content",
750
+ value: [{ type: "text", text: "cached weather receipt" }],
751
+ });
752
+ });
753
+
754
+ it("falls back to default model output when no custom projector is defined", async () => {
755
+ const tools = await createWeatherTools();
756
+
757
+ const output = await tools.get_weather!.toModelOutput!({
758
+ toolCallId: "tc-weather",
759
+ input: {},
760
+ output: { location: "San Francisco" },
761
+ });
762
+
763
+ expect(output).toEqual({
764
+ type: "json",
765
+ value: { location: "San Francisco" },
766
+ });
767
+ });
768
+
769
+ it("uses stored model content envelopes when no custom projector is defined", async () => {
770
+ const tools = await createWeatherTools();
771
+
772
+ const output = await tools.get_weather!.toModelOutput!({
773
+ toolCallId: "tc-weather",
774
+ input: {},
775
+ output: wrapModelContentEnvelope({ location: "San Francisco" }, [
776
+ { type: "text", text: "cached weather receipt" },
777
+ ]),
778
+ });
779
+
780
+ expect(output).toEqual({
781
+ type: "content",
782
+ value: [{ type: "text", text: "cached weather receipt" }],
783
+ });
784
+ });
785
+ });