@tanstack/ai 0.26.1 → 0.28.0

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 (51) hide show
  1. package/dist/esm/activities/chat/index.d.ts +7 -6
  2. package/dist/esm/activities/chat/index.js +78 -27
  3. package/dist/esm/activities/chat/index.js.map +1 -1
  4. package/dist/esm/activities/chat/mcp/manager.d.ts +25 -0
  5. package/dist/esm/activities/chat/mcp/manager.js +71 -0
  6. package/dist/esm/activities/chat/mcp/manager.js.map +1 -0
  7. package/dist/esm/activities/chat/mcp/types.d.ts +56 -0
  8. package/dist/esm/activities/chat/middleware/types.d.ts +1 -4
  9. package/dist/esm/activities/chat/stream/message-updaters.js +20 -8
  10. package/dist/esm/activities/chat/stream/message-updaters.js.map +1 -1
  11. package/dist/esm/activities/chat/tools/tool-calls.d.ts +1 -1
  12. package/dist/esm/activities/chat/tools/tool-calls.js +2 -1
  13. package/dist/esm/activities/chat/tools/tool-calls.js.map +1 -1
  14. package/dist/esm/activities/summarize/chat-stream-summarize.js +62 -3
  15. package/dist/esm/activities/summarize/chat-stream-summarize.js.map +1 -1
  16. package/dist/esm/extend-adapter.d.ts +22 -6
  17. package/dist/esm/extend-adapter.js.map +1 -1
  18. package/dist/esm/index.d.ts +2 -0
  19. package/dist/esm/index.js +2 -0
  20. package/dist/esm/index.js.map +1 -1
  21. package/dist/esm/logger/internal-logger.d.ts +8 -0
  22. package/dist/esm/logger/internal-logger.js +15 -0
  23. package/dist/esm/logger/internal-logger.js.map +1 -1
  24. package/dist/esm/middlewares/otel.js +30 -6
  25. package/dist/esm/middlewares/otel.js.map +1 -1
  26. package/dist/esm/types.d.ts +6 -35
  27. package/dist/esm/utilities/sampling-keys.d.ts +20 -0
  28. package/dist/esm/utilities/sampling-keys.js +20 -0
  29. package/dist/esm/utilities/sampling-keys.js.map +1 -0
  30. package/package.json +2 -2
  31. package/skills/ai-core/adapter-configuration/SKILL.md +67 -6
  32. package/skills/ai-core/adapter-configuration/references/anthropic-adapter.md +6 -3
  33. package/skills/ai-core/adapter-configuration/references/gemini-adapter.md +3 -0
  34. package/skills/ai-core/adapter-configuration/references/ollama-adapter.md +10 -1
  35. package/skills/ai-core/adapter-configuration/references/openai-adapter.md +4 -0
  36. package/skills/ai-core/chat-experience/SKILL.md +95 -7
  37. package/skills/ai-core/middleware/SKILL.md +11 -0
  38. package/skills/ai-core/tool-calling/SKILL.md +287 -0
  39. package/src/activities/chat/index.ts +97 -35
  40. package/src/activities/chat/mcp/manager.ts +85 -0
  41. package/src/activities/chat/mcp/types.ts +66 -0
  42. package/src/activities/chat/middleware/types.ts +1 -4
  43. package/src/activities/chat/stream/message-updaters.ts +22 -9
  44. package/src/activities/chat/tools/tool-calls.ts +2 -0
  45. package/src/activities/summarize/chat-stream-summarize.ts +162 -3
  46. package/src/extend-adapter.ts +42 -24
  47. package/src/index.ts +10 -0
  48. package/src/logger/internal-logger.ts +18 -0
  49. package/src/middlewares/otel.ts +48 -6
  50. package/src/types.ts +6 -35
  51. package/src/utilities/sampling-keys.ts +28 -0
@@ -375,6 +375,293 @@ gets the full schema, then calls `compareProducts` directly.
375
375
  Once discovered, a tool stays available for the conversation.
376
376
  When all lazy tools are discovered, the discovery tool is removed automatically.
377
377
 
378
+ ## MCP Tools
379
+
380
+ `@tanstack/ai-mcp` lets a server-side `chat()` call discover and invoke tools
381
+ hosted on any MCP server (Streamable HTTP, SSE, or stdio).
382
+
383
+ ### Basic usage — auto-discovery
384
+
385
+ ```typescript
386
+ // src/routes/api.chat.ts
387
+ import { createFileRoute } from '@tanstack/react-router'
388
+ import { chat, toServerSentEventsResponse } from '@tanstack/ai'
389
+ import { openaiText } from '@tanstack/ai-openai'
390
+ import { createMCPClient } from '@tanstack/ai-mcp'
391
+
392
+ export const Route = createFileRoute('/api/chat')({
393
+ server: {
394
+ handlers: {
395
+ POST: async ({ request }) => {
396
+ const { messages } = await request.json()
397
+
398
+ // 1. Connect to the MCP server.
399
+ const mcp = await createMCPClient({
400
+ transport: { type: 'http', url: 'https://mcp.example.com/mcp' },
401
+ })
402
+
403
+ // 2. Discover all tools from the server (returns ServerTool[]).
404
+ const mcpTools = await mcp.tools()
405
+
406
+ // 3. Spread them into chat() — they work exactly like hand-written tools.
407
+ // Caller owns the lifecycle — chat() never closes the client. Tools run
408
+ // while the response streams, so close in a middleware terminal hook
409
+ // (a try/finally around the return would close before tools execute).
410
+ const stream = chat({
411
+ adapter: openaiText('gpt-5.5'),
412
+ messages,
413
+ tools: [...mcpTools],
414
+ middleware: [
415
+ {
416
+ name: 'mcp-close',
417
+ onFinish: () => mcp.close(),
418
+ onAbort: () => mcp.close(),
419
+ onError: () => mcp.close(),
420
+ },
421
+ ],
422
+ })
423
+ return toServerSentEventsResponse(stream)
424
+ },
425
+ },
426
+ },
427
+ })
428
+ ```
429
+
430
+ ### Typed path — pass toolDefinition instances
431
+
432
+ Pass bare `toolDefinition()` instances (no `.server()`) to `client.tools([...])`.
433
+ The MCP client supplies a `callTool` proxy as the execute function, while
434
+ input/output validation and types come from the definitions' Zod schemas.
435
+
436
+ ```typescript
437
+ import { toolDefinition } from '@tanstack/ai'
438
+ import { createMCPClient } from '@tanstack/ai-mcp'
439
+ import { z } from 'zod'
440
+
441
+ const getWeather = toolDefinition({
442
+ name: 'get_weather',
443
+ description: 'Current weather for a city',
444
+ inputSchema: z.object({ city: z.string() }),
445
+ outputSchema: z.object({ temperature: z.number(), conditions: z.string() }),
446
+ })
447
+
448
+ const mcp = await createMCPClient({
449
+ transport: { type: 'http', url: 'https://mcp.example.com/mcp' },
450
+ })
451
+
452
+ // Returns ServerTool[] typed to the definitions' input/output schemas.
453
+ // Throws MCPToolNotFoundError if the server does not expose a tool with that name.
454
+ const tools = await mcp.tools([getWeather])
455
+
456
+ const stream = chat({ adapter: openaiText('gpt-5.5'), messages, tools })
457
+ ```
458
+
459
+ ### Multiple servers with `createMCPClients`
460
+
461
+ ```typescript
462
+ import { createMCPClients } from '@tanstack/ai-mcp'
463
+
464
+ // Each key becomes the default prefix for that server's tools.
465
+ await using pool = await createMCPClients({
466
+ github: { transport: { type: 'http', url: 'https://mcp.github.com/mcp' } },
467
+ linear: { transport: { type: 'http', url: 'https://mcp.linear.app/mcp' } },
468
+ })
469
+
470
+ // Tools auto-prefixed: 'github_search_repos', 'linear_create_issue', etc.
471
+ const tools = await pool.tools()
472
+
473
+ const stream = chat({ adapter: openaiText('gpt-5.5'), messages, tools })
474
+ ```
475
+
476
+ Use `pool.clients.<name>` for typed per-server access (resources, prompts, typed
477
+ `tools([defs])` overload).
478
+
479
+ ### `ToolExecutionContext.abortSignal` — cancelling long-running tools
480
+
481
+ Every server tool's execute function now receives `abortSignal` in its context.
482
+ When the chat run aborts (e.g. the client disconnects or calls the run's
483
+ `abortController`), the signal fires and any in-flight `callTool` call is
484
+ cancelled automatically.
485
+
486
+ You can also forward it from your own server tools:
487
+
488
+ ```typescript
489
+ const longRunningTool = myToolDef.server(async (args, ctx) => {
490
+ // Forward to fetch, a DB query, or an MCP callTool call.
491
+ const response = await fetch('https://slow.api/data', {
492
+ signal: ctx?.abortSignal,
493
+ })
494
+ return response.json()
495
+ })
496
+ ```
497
+
498
+ MCP tools wire this automatically — `makeMcpExecute` passes `ctx?.abortSignal`
499
+ as the `signal` option to `client.callTool(...)`, so MCP server calls cancel
500
+ with the chat run without any extra code.
501
+
502
+ ### stdio transport (Node-only)
503
+
504
+ ```typescript
505
+ import { createMCPClient } from '@tanstack/ai-mcp'
506
+ import { stdioTransport } from '@tanstack/ai-mcp/stdio'
507
+
508
+ const mcp = await createMCPClient({
509
+ transport: stdioTransport({ command: 'npx', args: ['-y', 'my-mcp-server'] }),
510
+ })
511
+ ```
512
+
513
+ Import `stdioTransport` from the `/stdio` subpath only — it contains Node.js
514
+ `child_process` imports and must not be bundled for edge runtimes.
515
+
516
+ ### `chat({ mcp })` — discovery + lifecycle in one prop
517
+
518
+ Instead of manually calling `client.tools()` and managing `close()`, pass an
519
+ `mcp` object and let `chat()` handle discovery and lifecycle.
520
+
521
+ ```typescript
522
+ // Prop shape (ChatMCPOptions):
523
+ // mcp: {
524
+ // clients: Array<MCPClient | MCPClients>,
525
+ // connection?: 'close' | 'keep-alive', // default: 'close'
526
+ // lazyTools?: boolean,
527
+ // onDiscoveryError?: (error: unknown, source) => void,
528
+ // }
529
+ ```
530
+
531
+ - At run start, `chat()` calls `.tools()` on every entry in `clients` and merges
532
+ the results — identical to spreading `await client.tools()` into `tools: [...]`.
533
+ - `lazyTools: true` is forwarded to `tools({ lazy: true })`.
534
+ - `onDiscoveryError`: throw to fail-fast; return to skip that source.
535
+ - `connection: 'close'` (default) closes each client when the run ends (after
536
+ the agent loop completes and the stream is drained). With `'keep-alive'`,
537
+ `chat()` never closes the clients — the caller owns their lifecycle (keep
538
+ connections warm across requests).
539
+
540
+ **When to use `mcp` vs. the tools spread:**
541
+
542
+ | Approach | Use when |
543
+ | ------------------------------------------------------- | --------------------------------------------------------------------------------- |
544
+ | `chat({ mcp: { clients: [...] } })` | Convenience: discovery + lifecycle in one place; untyped tool args are acceptable |
545
+ | `tools: [...await client.tools([toolDefinition(...)])]` | Fully-typed tool args/results via Zod schemas |
546
+
547
+ **Example:**
548
+
549
+ ```typescript
550
+ import { createFileRoute } from '@tanstack/react-router'
551
+ import { chat, toServerSentEventsResponse } from '@tanstack/ai'
552
+ import { openaiText } from '@tanstack/ai-openai'
553
+ import { createMCPClient } from '@tanstack/ai-mcp'
554
+
555
+ export const Route = createFileRoute('/api/chat')({
556
+ server: {
557
+ handlers: {
558
+ POST: async ({ request }) => {
559
+ const { messages } = await request.json()
560
+
561
+ const mcpClient = await createMCPClient({
562
+ transport: { type: 'http', url: 'https://mcp.example.com/mcp' },
563
+ })
564
+
565
+ const stream = chat({
566
+ adapter: openaiText('gpt-5.5'),
567
+ messages,
568
+ mcp: {
569
+ clients: [mcpClient],
570
+ connection: 'keep-alive',
571
+ onDiscoveryError: (err, source) => {
572
+ console.warn('MCP discovery failed, skipping source:', err)
573
+ // returning (not throwing) skips this source and continues
574
+ },
575
+ },
576
+ })
577
+
578
+ return toServerSentEventsResponse(stream)
579
+ },
580
+ },
581
+ },
582
+ })
583
+ ```
584
+
585
+ ## Provider Skills
586
+
587
+ > **Not to be confused with `@tanstack/ai-code-mode-skills`**, which are locally-generated TypeScript functions executed client-side. Provider Skills are hosted, provider-managed bundles that the model loads on demand and runs inside the provider's server-side sandbox.
588
+
589
+ Provider Skills are inert without an execution tool. The execution tool is what activates the sandbox; skills are additional capability bundles that run inside it:
590
+
591
+ - **Anthropic**: skills require the `code_execution` tool (`@tanstack/ai-anthropic/tools`).
592
+ - **OpenAI**: skills live inside the `shell` tool (`@tanstack/ai-openai/tools`) and are Responses API only.
593
+
594
+ ### Anthropic: `codeExecutionTool` with skills
595
+
596
+ Import from `@tanstack/ai-anthropic/tools`:
597
+
598
+ ```typescript
599
+ import { codeExecutionTool } from '@tanstack/ai-anthropic/tools'
600
+ import { chat, toServerSentEventsResponse } from '@tanstack/ai'
601
+ import { anthropicText } from '@tanstack/ai-anthropic'
602
+
603
+ export async function POST(request: Request) {
604
+ const { messages } = await request.json()
605
+ const stream = chat({
606
+ adapter: anthropicText('claude-sonnet-4-5'),
607
+ messages,
608
+ tools: [
609
+ codeExecutionTool(
610
+ { type: 'code_execution_20250825', name: 'code_execution' },
611
+ {
612
+ skills: [{ type: 'anthropic', skill_id: 'pptx', version: 'latest' }],
613
+ },
614
+ ),
615
+ ],
616
+ })
617
+ return toServerSentEventsResponse(stream)
618
+ }
619
+ ```
620
+
621
+ `AnthropicContainerSkill` shape: `{ type: 'anthropic' | 'custom'; skill_id: string; version?: string }`. Constraints: max 8 skills per request; `skill_id` must be 1–64 characters.
622
+
623
+ The adapter automatically:
624
+
625
+ - Lifts the skills into the request's top-level `container.skills` param (the shape Anthropic's API requires).
626
+ - Attaches the required beta headers (`code-execution-2025-08-25` plus `skills-2025-10-02` when skills are present). You do not set these manually.
627
+
628
+ **Deprecation:** Setting skills via `modelOptions.container.skills` is deprecated. Use `codeExecutionTool(config, { skills })` instead — the legacy path bypasses the beta-header wiring.
629
+
630
+ ### OpenAI: `shellTool` with skills (Responses API only)
631
+
632
+ Import from `@tanstack/ai-openai/tools`:
633
+
634
+ ```typescript
635
+ import { shellTool } from '@tanstack/ai-openai/tools'
636
+ import { chat, toServerSentEventsResponse } from '@tanstack/ai'
637
+ import { openaiText } from '@tanstack/ai-openai'
638
+
639
+ export async function POST(request: Request) {
640
+ const { messages } = await request.json()
641
+ const stream = chat({
642
+ adapter: openaiText('gpt-5.2'),
643
+ messages,
644
+ tools: [
645
+ shellTool({
646
+ environment: {
647
+ type: 'container_auto',
648
+ skills: [
649
+ { type: 'skill_reference', skill_id: 'skill_abc', version: '2' },
650
+ ],
651
+ },
652
+ }),
653
+ ],
654
+ })
655
+ return toServerSentEventsResponse(stream)
656
+ }
657
+ ```
658
+
659
+ `SkillReference` shape: `{ type: 'skill_reference'; skill_id: string; version?: string }`. `version` is a string — use a positive integer as a string (e.g. `'2'`) or `'latest'`. This is Responses API only; Chat Completions does not support the shell tool.
660
+
661
+ ### Scope
662
+
663
+ Only hosted/managed-by-id skills (`type: 'anthropic'` / `type: 'custom'` for Anthropic; `type: 'skill_reference'` for OpenAI) are wired. Inline bundles, local-path, and upload-API skill creation are not handled by these factories.
664
+
378
665
  ## Common Mistakes
379
666
 
380
667
  ### a. HIGH: Not passing tool definitions to both server and client
@@ -25,6 +25,7 @@ import {
25
25
  import { maxIterations as maxIterationsStrategy } from './agent-loop-strategies'
26
26
  import { convertMessagesToModelMessages, generateMessageId } from './messages'
27
27
  import { MiddlewareRunner } from './middleware/compose'
28
+ import { MCPManager } from './mcp/manager'
28
29
  import type {
29
30
  ApprovalRequest,
30
31
  ClientToolRequest,
@@ -69,6 +70,7 @@ import type {
69
70
  MergeContext,
70
71
  UnionToIntersection,
71
72
  } from './runtime-context-types'
73
+ import type { ChatMCPOptions } from './mcp/types'
72
74
 
73
75
  // ===========================
74
76
  // Activity Kind
@@ -208,12 +210,12 @@ export interface TextActivityOptions<
208
210
  | ProviderTool<string, TAdapter['~types']['toolCapabilities'][number]>
209
211
  >
210
212
  | undefined
211
- /** Controls the randomness of the output. Higher values make output more random. Range: [0.0, 2.0] */
212
- temperature?: TextOptions['temperature']
213
- /** Nucleus sampling parameter. The model considers tokens with topP probability mass. */
214
- topP?: TextOptions['topP']
215
- /** The maximum number of tokens to generate in the response. */
216
- maxTokens?: TextOptions['maxTokens']
213
+ /**
214
+ * Hand MCP clients/pools to chat(): their tools are discovered at run start
215
+ * and merged into the run; `connection` controls whether chat() closes them
216
+ * when the run ends. See docs/tools/mcp.md "Managing MCP clients with chat()".
217
+ */
218
+ mcp?: ChatMCPOptions
217
219
  /** Additional metadata to attach to the request. */
218
220
  metadata?: TextOptions['metadata']
219
221
  /** Model-specific provider options (type comes from adapter) */
@@ -438,6 +440,28 @@ interface TextEngineConfig<
438
440
  type ToolPhaseResult = 'continue' | 'stop' | 'wait'
439
441
  type CyclePhase = 'processText' | 'executeToolCalls'
440
442
 
443
+ /**
444
+ * Combine two optional AbortSignals into one that aborts when either does.
445
+ * Returns the other signal directly when one is absent or already aborted.
446
+ * (Manual implementation — `AbortSignal.any` requires Node >= 20.3.)
447
+ */
448
+ function combineAbortSignals(
449
+ a: AbortSignal | undefined,
450
+ b: AbortSignal | undefined,
451
+ ): AbortSignal | undefined {
452
+ if (!a) return b
453
+ if (!b) return a
454
+ if (a.aborted) return a
455
+ if (b.aborted) return b
456
+ const controller = new AbortController()
457
+ const onAbort = (source: AbortSignal) => () => {
458
+ controller.abort(source.reason)
459
+ }
460
+ a.addEventListener('abort', onAbort(a), { once: true })
461
+ b.addEventListener('abort', onAbort(b), { once: true })
462
+ return controller.signal
463
+ }
464
+
441
465
  class TextEngine<
442
466
  TAdapter extends AnyTextAdapter,
443
467
  TContext = unknown,
@@ -492,6 +516,9 @@ class TextEngine<
492
516
  private readonly deferredPromises: Array<Promise<unknown>> = []
493
517
  private abortReason?: string
494
518
  private readonly middlewareAbortController?: AbortController
519
+ // Combines the caller's signal with middleware abort() so running tools
520
+ // observe both cancellation sources via ctx.abortSignal.
521
+ private readonly toolAbortSignal?: AbortSignal
495
522
  private terminalHookCalled = false
496
523
 
497
524
  private readonly logger: InternalLogger
@@ -589,6 +616,10 @@ class TextEngine<
589
616
  ]
590
617
  this.middlewareRunner = new MiddlewareRunner(allMiddleware, logger)
591
618
  this.middlewareAbortController = new AbortController()
619
+ this.toolAbortSignal = combineAbortSignals(
620
+ this.effectiveSignal,
621
+ this.middlewareAbortController.signal,
622
+ )
592
623
  this.middlewareCtx = {
593
624
  requestId: this.requestId,
594
625
  streamId: this.streamId,
@@ -844,13 +875,10 @@ class TextEngine<
844
875
 
845
876
  private beforeRun(): void {
846
877
  this.streamStartTime = Date.now()
847
- const { tools, temperature, topP, maxTokens, metadata } = this.params
878
+ const { tools, metadata } = this.params
848
879
 
849
880
  // Gather flattened options into an object for context
850
881
  const options: Record<string, unknown> = {}
851
- if (temperature !== undefined) options.temperature = temperature
852
- if (topP !== undefined) options.topP = topP
853
- if (maxTokens !== undefined) options.maxTokens = maxTokens
854
882
  if (metadata !== undefined) options.metadata = metadata
855
883
 
856
884
  this.eventOptions = Object.keys(options).length > 0 ? options : undefined
@@ -897,7 +925,7 @@ class TextEngine<
897
925
  }
898
926
 
899
927
  private async *streamModelResponse(): AsyncGenerator<StreamChunk> {
900
- const { temperature, topP, maxTokens, metadata, modelOptions } = this.params
928
+ const { metadata, modelOptions } = this.params
901
929
  const tools = this.tools
902
930
 
903
931
  // Convert tool schemas to JSON Schema before passing to adapter
@@ -941,9 +969,6 @@ class TextEngine<
941
969
  model: this.params.model,
942
970
  messages: this.messages,
943
971
  tools: toolsWithJsonSchemas,
944
- temperature,
945
- topP,
946
- maxTokens,
947
972
  metadata,
948
973
  request: this.effectiveRequest,
949
974
  modelOptions,
@@ -1237,6 +1262,7 @@ class TextEngine<
1237
1262
  },
1238
1263
  },
1239
1264
  this.middlewareCtx.context,
1265
+ this.toolAbortSignal,
1240
1266
  )
1241
1267
 
1242
1268
  // Consume the async generator, yielding custom events and collecting the return value
@@ -1398,6 +1424,7 @@ class TextEngine<
1398
1424
  },
1399
1425
  },
1400
1426
  this.middlewareCtx.context,
1427
+ this.toolAbortSignal,
1401
1428
  )
1402
1429
 
1403
1430
  // Consume the async generator, yielding custom events and collecting the return value
@@ -1869,9 +1896,6 @@ class TextEngine<
1869
1896
  chatOptions: {
1870
1897
  model: this.params.model,
1871
1898
  messages: this.messages,
1872
- temperature: postOnConfig.temperature,
1873
- topP: postOnConfig.topP,
1874
- maxTokens: postOnConfig.maxTokens,
1875
1899
  metadata: postOnConfig.metadata,
1876
1900
  modelOptions: postOnConfig.modelOptions,
1877
1901
  systemPrompts: postOnConfig.systemPrompts,
@@ -2351,9 +2375,6 @@ class TextEngine<
2351
2375
  messages: this.messages,
2352
2376
  systemPrompts: [...this.systemPrompts],
2353
2377
  tools: [...this.tools],
2354
- temperature: this.params.temperature,
2355
- topP: this.params.topP,
2356
- maxTokens: this.params.maxTokens,
2357
2378
  metadata: this.params.metadata,
2358
2379
  modelOptions: this.params.modelOptions,
2359
2380
  }
@@ -2365,9 +2386,6 @@ class TextEngine<
2365
2386
  this.tools = config.tools
2366
2387
  this.params = {
2367
2388
  ...this.params,
2368
- temperature: config.temperature,
2369
- topP: config.topP,
2370
- maxTokens: config.maxTokens,
2371
2389
  metadata: config.metadata,
2372
2390
  modelOptions: config.modelOptions,
2373
2391
  }
@@ -2589,10 +2607,16 @@ export function chat<
2589
2607
  async function* runStreamingText<TContext = unknown>(
2590
2608
  options: TextActivityOptions<AnyTextAdapter, undefined, true, TContext>,
2591
2609
  ): AsyncIterable<StreamChunk> {
2592
- const { adapter, middleware, context, debug, ...textOptions } = options
2610
+ const { adapter, middleware, context, debug, mcp, ...textOptions } = options
2593
2611
  const model = adapter.model
2594
2612
  const logger = resolveDebugOption(debug)
2595
2613
 
2614
+ const mcpManager = MCPManager.from(mcp)
2615
+ const mcpTools = await mcpManager.discover()
2616
+ if (mcpTools.length > 0) {
2617
+ textOptions.tools = [...(textOptions.tools ?? []), ...mcpTools]
2618
+ }
2619
+
2596
2620
  const engine = new TextEngine(
2597
2621
  {
2598
2622
  adapter,
@@ -2607,8 +2631,12 @@ async function* runStreamingText<TContext = unknown>(
2607
2631
  logger,
2608
2632
  )
2609
2633
 
2610
- for await (const chunk of engine.run()) {
2611
- yield chunk
2634
+ try {
2635
+ for await (const chunk of engine.run()) {
2636
+ yield chunk
2637
+ }
2638
+ } finally {
2639
+ await mcpManager.dispose()
2612
2640
  }
2613
2641
  }
2614
2642
 
@@ -2645,8 +2673,15 @@ async function runAgenticStructuredOutput<
2645
2673
  >(
2646
2674
  options: TextActivityOptions<AnyTextAdapter, TSchema, boolean, TContext>,
2647
2675
  ): Promise<InferSchemaType<TSchema>> {
2648
- const { adapter, outputSchema, middleware, context, debug, ...textOptions } =
2649
- options
2676
+ const {
2677
+ adapter,
2678
+ outputSchema,
2679
+ middleware,
2680
+ context,
2681
+ debug,
2682
+ mcp,
2683
+ ...textOptions
2684
+ } = options
2650
2685
  const model = adapter.model
2651
2686
  const logger = resolveDebugOption(debug)
2652
2687
 
@@ -2680,6 +2715,12 @@ async function runAgenticStructuredOutput<
2680
2715
  const nativeCombined =
2681
2716
  adapter.supportsCombinedToolsAndSchema?.(options.modelOptions) === true
2682
2717
 
2718
+ const mcpManager = MCPManager.from(mcp)
2719
+ const mcpTools = await mcpManager.discover()
2720
+ if (mcpTools.length > 0) {
2721
+ textOptions.tools = [...(textOptions.tools ?? []), ...mcpTools]
2722
+ }
2723
+
2683
2724
  const engine = new TextEngine(
2684
2725
  {
2685
2726
  adapter,
@@ -2700,9 +2741,13 @@ async function runAgenticStructuredOutput<
2700
2741
  logger,
2701
2742
  )
2702
2743
 
2703
- // Consume the stream — chunks pipe through middleware but are not yielded externally
2704
- for await (const _chunk of engine.run()) {
2705
- // intentionally empty
2744
+ try {
2745
+ // Consume the stream — chunks pipe through middleware but are not yielded externally
2746
+ for await (const _chunk of engine.run()) {
2747
+ // intentionally empty
2748
+ }
2749
+ } finally {
2750
+ await mcpManager.dispose()
2706
2751
  }
2707
2752
 
2708
2753
  const finalizationError = engine.getFinalizationError()
@@ -2933,8 +2978,15 @@ async function* runStreamingStructuredOutputImpl<
2933
2978
  options: TextActivityOptions<AnyTextAdapter, TSchema, true, TContext>,
2934
2979
  jsonSchema: NonNullable<ReturnType<typeof convertSchemaToJsonSchema>>,
2935
2980
  ): StructuredOutputStreamInternal<InferSchemaType<TSchema>> {
2936
- const { adapter, outputSchema, middleware, context, debug, ...textOptions } =
2937
- options
2981
+ const {
2982
+ adapter,
2983
+ outputSchema,
2984
+ middleware,
2985
+ context,
2986
+ debug,
2987
+ mcp,
2988
+ ...textOptions
2989
+ } = options
2938
2990
  const model = adapter.model
2939
2991
  const logger = resolveDebugOption(debug)
2940
2992
 
@@ -2948,6 +3000,12 @@ async function* runStreamingStructuredOutputImpl<
2948
3000
  const nativeCombined =
2949
3001
  adapter.supportsCombinedToolsAndSchema?.(options.modelOptions) === true
2950
3002
 
3003
+ const mcpManager = MCPManager.from(mcp)
3004
+ const mcpTools = await mcpManager.discover()
3005
+ if (mcpTools.length > 0) {
3006
+ textOptions.tools = [...(textOptions.tools ?? []), ...mcpTools]
3007
+ }
3008
+
2951
3009
  // Inputs may be UIMessages (from useChat) or ModelMessages (from server-side
2952
3010
  // callers). TextEngine handles the conversion uniformly.
2953
3011
  const engine = new TextEngine(
@@ -2969,8 +3027,12 @@ async function* runStreamingStructuredOutputImpl<
2969
3027
  logger,
2970
3028
  )
2971
3029
 
2972
- for await (const chunk of engine.run()) {
2973
- yield chunk
3030
+ try {
3031
+ for await (const chunk of engine.run()) {
3032
+ yield chunk
3033
+ }
3034
+ } finally {
3035
+ await mcpManager.dispose()
2974
3036
  }
2975
3037
 
2976
3038
  // Schema validation for the streaming variant remains the consumer's
@@ -0,0 +1,85 @@
1
+ import type { ServerTool } from '../tools/tool-definition'
2
+ import type { ChatMCPOptions, MCPToolSource } from './types'
3
+
4
+ export class MCPDuplicateToolNameError extends Error {
5
+ constructor(public readonly toolName: string) {
6
+ super(
7
+ `Duplicate MCP tool name "${toolName}" in chat({ mcp.clients }). ` +
8
+ `Set a unique \`prefix\` on one of the MCP clients (or use a pool, ` +
9
+ `which auto-prefixes) to disambiguate.`,
10
+ )
11
+ this.name = 'MCPDuplicateToolNameError'
12
+ }
13
+ }
14
+
15
+ /**
16
+ * Encapsulates MCP tool discovery + connection lifecycle for chat().
17
+ * Built from chat()'s `mcp` option; runners only call `discover()` then
18
+ * `dispose()`. A manager built from `undefined` is an inert no-op
19
+ * (`discover()` → `[]`, `dispose()` → no-op), so runners need no branching.
20
+ */
21
+ export class MCPManager {
22
+ static from(options: ChatMCPOptions | undefined): MCPManager {
23
+ return new MCPManager(options)
24
+ }
25
+
26
+ readonly #sources: ReadonlyArray<MCPToolSource>
27
+ readonly #shouldClose: boolean
28
+ readonly #lazyTools: boolean
29
+ readonly #onDiscoveryError?: (
30
+ error: unknown,
31
+ source: MCPToolSource,
32
+ ) => void | Promise<void>
33
+
34
+ private constructor(options: ChatMCPOptions | undefined) {
35
+ this.#sources = options?.clients ?? []
36
+ // default 'close'; only 'keep-alive' disables closing
37
+ this.#shouldClose = options ? options.connection !== 'keep-alive' : false
38
+ this.#lazyTools = options?.lazyTools ?? false
39
+ this.#onDiscoveryError = options?.onDiscoveryError
40
+ }
41
+
42
+ /**
43
+ * Discover + merge tools from all sources. Throws on a fatal discovery error
44
+ * (no `onDiscoveryError`, or it re-threw) or a duplicate tool name; in that
45
+ * case it first closes any connected sources when the policy is 'close'.
46
+ */
47
+ async discover(): Promise<Array<ServerTool>> {
48
+ if (this.#sources.length === 0) return []
49
+ try {
50
+ const settled = await Promise.allSettled(
51
+ this.#sources.map((s) => s.tools({ lazy: this.#lazyTools })),
52
+ )
53
+ const tools: Array<ServerTool> = []
54
+ const zipped = this.#sources.map(
55
+ (source, i) => [source, settled[i]] as const,
56
+ )
57
+ for (const [source, result] of zipped) {
58
+ if (result === undefined) continue
59
+ if (result.status === 'fulfilled') {
60
+ tools.push(...result.value)
61
+ } else if (this.#onDiscoveryError) {
62
+ // throw/reject inside handler ⇒ propagate (fail-fast); return ⇒ skip
63
+ await this.#onDiscoveryError(result.reason, source)
64
+ } else {
65
+ throw result.reason
66
+ }
67
+ }
68
+ const seen = new Set<string>()
69
+ for (const t of tools) {
70
+ if (seen.has(t.name)) throw new MCPDuplicateToolNameError(t.name)
71
+ seen.add(t.name)
72
+ }
73
+ return tools
74
+ } catch (err) {
75
+ await this.dispose() // cleanup-on-failure (no-op if keep-alive)
76
+ throw err
77
+ }
78
+ }
79
+
80
+ /** Close sources iff policy is 'close'. Idempotent; never throws. */
81
+ async dispose(): Promise<void> {
82
+ if (!this.#shouldClose || this.#sources.length === 0) return
83
+ await Promise.allSettled(this.#sources.map((s) => s.close()))
84
+ }
85
+ }