@tanstack/ai 0.27.0 → 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.
@@ -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
+ }
@@ -0,0 +1,66 @@
1
+ import type { ServerTool } from '../tools/tool-definition'
2
+
3
+ /**
4
+ * Minimal structural shape that `chat({ mcp })` needs from an MCP client.
5
+ *
6
+ * `@tanstack/ai-mcp`'s `MCPClient` and `MCPClients` satisfy this interface by
7
+ * shape — the core `@tanstack/ai` package does NOT import `@tanstack/ai-mcp`
8
+ * (ai-mcp depends on ai, not the reverse).
9
+ */
10
+ export interface MCPToolSource {
11
+ // Keep the options shape in sync with ai-mcp's `ToolsOptions` — extra
12
+ // optional fields added there still match structurally, but chat() only
13
+ // forwards what is declared here.
14
+ tools: (options?: { lazy?: boolean }) => Promise<Array<ServerTool>>
15
+ close: () => Promise<void>
16
+ }
17
+
18
+ /**
19
+ * Controls what happens to MCP connections when the chat run ends.
20
+ *
21
+ * - `'close'` (default) — `chat()` closes each connection when the run ends
22
+ * (after the agent loop completes and the stream is drained), so tools can
23
+ * still execute throughout the run.
24
+ * - `'keep-alive'` — `chat()` never closes the connections; the caller owns
25
+ * their lifecycle (e.g. keep them warm across requests).
26
+ */
27
+ export type MCPConnectionPolicy = 'close' | 'keep-alive'
28
+
29
+ /**
30
+ * Options controlling MCP tool discovery and lifecycle for a `chat()` call.
31
+ */
32
+ export interface ChatMCPOptions {
33
+ /**
34
+ * The MCP clients or client pools to discover tools from and manage.
35
+ */
36
+ clients: Array<MCPToolSource>
37
+
38
+ /**
39
+ * Connection lifecycle policy applied to all clients when the run ends.
40
+ *
41
+ * Defaults to `'close'`.
42
+ */
43
+ connection?: MCPConnectionPolicy
44
+
45
+ /**
46
+ * When `true`, tool schemas are fetched lazily (forwarded to
47
+ * `tools({ lazy: true })`).
48
+ *
49
+ * Defaults to `false`.
50
+ */
51
+ lazyTools?: boolean
52
+
53
+ /**
54
+ * Called when tool discovery fails for a single source.
55
+ *
56
+ * - Throw (or re-throw) from this handler to fail the entire chat call fast.
57
+ * - Return normally to skip that source and continue with remaining clients.
58
+ * - Omit this handler entirely to rethrow the error (fail-fast by default).
59
+ *
60
+ * Async handlers are awaited, so a rejected promise also fails fast.
61
+ */
62
+ onDiscoveryError?: (
63
+ error: unknown,
64
+ source: MCPToolSource,
65
+ ) => void | Promise<void>
66
+ }
@@ -161,10 +161,14 @@ export function updateToolCallApproval(
161
161
  )
162
162
 
163
163
  if (toolCallPart) {
164
- toolCallPart.state = 'approval-requested'
165
- toolCallPart.approval = {
166
- id: approvalId,
167
- needsApproval: true,
164
+ const index = parts.indexOf(toolCallPart)
165
+ parts[index] = {
166
+ ...toolCallPart,
167
+ state: 'approval-requested',
168
+ approval: {
169
+ id: approvalId,
170
+ needsApproval: true,
171
+ },
168
172
  }
169
173
  }
170
174
 
@@ -192,7 +196,8 @@ export function updateToolCallState(
192
196
  )
193
197
 
194
198
  if (toolCallPart) {
195
- toolCallPart.state = state
199
+ const index = parts.indexOf(toolCallPart)
200
+ parts[index] = { ...toolCallPart, state }
196
201
  }
197
202
 
198
203
  return { ...msg, parts }
@@ -217,8 +222,12 @@ export function updateToolCallWithOutput(
217
222
  )
218
223
 
219
224
  if (toolCallPart) {
220
- toolCallPart.output = errorText ? { error: errorText } : output
221
- toolCallPart.state = state ?? (errorText ? 'input-complete' : 'complete')
225
+ const index = parts.indexOf(toolCallPart)
226
+ parts[index] = {
227
+ ...toolCallPart,
228
+ output: errorText ? { error: errorText } : output,
229
+ state: state ?? (errorText ? 'input-complete' : 'complete'),
230
+ }
222
231
  }
223
232
 
224
233
  return { ...msg, parts }
@@ -242,8 +251,12 @@ export function updateToolCallApprovalResponse(
242
251
  )
243
252
 
244
253
  if (toolCallPart && toolCallPart.approval) {
245
- toolCallPart.approval.approved = approved
246
- toolCallPart.state = 'approval-responded'
254
+ const index = parts.indexOf(toolCallPart)
255
+ parts[index] = {
256
+ ...toolCallPart,
257
+ approval: { ...toolCallPart.approval, approved },
258
+ state: 'approval-responded',
259
+ }
247
260
  }
248
261
 
249
262
  return { ...msg, parts }
@@ -599,6 +599,7 @@ export async function* executeToolCalls<TContext = unknown>(
599
599
  ) => CustomEvent,
600
600
  middlewareHooks?: ToolExecutionMiddlewareHooks,
601
601
  userContext?: TContext,
602
+ abortSignal?: AbortSignal,
602
603
  ): AsyncGenerator<CustomEvent, ExecuteToolCallsResult, void> {
603
604
  const results: Array<ToolResult> = []
604
605
  const needsApproval: Array<ApprovalRequest> = []
@@ -679,6 +680,7 @@ export async function* executeToolCalls<TContext = unknown>(
679
680
  const context = {
680
681
  toolCallId: toolCall.id,
681
682
  context: userContext,
683
+ abortSignal,
682
684
  emitCustomEvent: (eventName: string, value: Record<string, any>) => {
683
685
  if (createCustomEventChunk) {
684
686
  pendingEvents.push(
@@ -144,6 +144,14 @@ type ExtractCustomModelNames<TDefs extends ReadonlyArray<ExtendedModelDef>> =
144
144
  // Factory Type Inference
145
145
  // ===========================
146
146
 
147
+ /**
148
+ * The widest factory shape `extendAdapter` accepts: any function taking a
149
+ * model as its first parameter. Parameters are contravariant, so `never`
150
+ * params and an `unknown` return accept every factory without resorting
151
+ * to `any`.
152
+ */
153
+ type AnyAdapterFactory = (model: never, ...args: Array<never>) => unknown
154
+
147
155
  /**
148
156
  * Infer the model parameter type from an adapter factory function.
149
157
  * For generic functions like `<T extends Union>(model: T)`, this gets `T` which
@@ -151,32 +159,44 @@ type ExtractCustomModelNames<TDefs extends ReadonlyArray<ExtendedModelDef>> =
151
159
  */
152
160
  type InferFactoryModels<TFactory> = TFactory extends (
153
161
  model: infer TModel,
154
- ...args: Array<any>
155
- ) => any
162
+ ...args: Array<never>
163
+ ) => unknown
156
164
  ? TModel extends string
157
165
  ? TModel
158
166
  : string
159
167
  : string
160
168
 
161
- /**
162
- * Infer the config parameter type from an adapter factory function.
163
- */
164
- type InferConfig<TFactory> = TFactory extends (
165
- model: any,
166
- config?: infer TConfig,
167
- ) => any
168
- ? TConfig
169
- : undefined
170
-
171
169
  /**
172
170
  * Infer the adapter return type from a factory function.
173
171
  */
174
172
  type InferAdapterReturn<TFactory> = TFactory extends (
175
- ...args: Array<any>
173
+ ...args: Array<never>
176
174
  ) => infer TReturn
177
175
  ? TReturn
178
176
  : never
179
177
 
178
+ /**
179
+ * Extracts all parameter types after the model parameter from a factory,
180
+ * preserving labels and optionality (e.g. `[apiKey: string, config?: C]`).
181
+ * Note: overloaded factories resolve against their last overload (a
182
+ * `Parameters` limitation).
183
+ */
184
+ type InferRestArgs<TFactory extends AnyAdapterFactory> =
185
+ Parameters<TFactory> extends [unknown?, ...infer TRest] ? TRest : []
186
+
187
+ /**
188
+ * The factory signature produced by `extendAdapter`: accepts both original
189
+ * and custom model names while preserving all remaining parameters and the
190
+ * return type of the original factory.
191
+ */
192
+ type ExtendedFactory<
193
+ TFactory extends AnyAdapterFactory,
194
+ TDefs extends ReadonlyArray<ExtendedModelDef>,
195
+ > = (
196
+ model: InferFactoryModels<TFactory> | ExtractCustomModelNames<TDefs>,
197
+ ...args: InferRestArgs<TFactory>
198
+ ) => InferAdapterReturn<TFactory>
199
+
180
200
  // ===========================
181
201
  // extendAdapter Function
182
202
  // ===========================
@@ -225,19 +245,17 @@ type InferAdapterReturn<TFactory> = TFactory extends (
225
245
  * ```
226
246
  */
227
247
  export function extendAdapter<
228
- TFactory extends (...args: Array<any>) => any,
248
+ TFactory extends AnyAdapterFactory,
229
249
  const TDefs extends ReadonlyArray<ExtendedModelDef>,
230
- >(
231
- factory: TFactory,
232
- _customModels: TDefs,
233
- ): (
234
- model: InferFactoryModels<TFactory> | ExtractCustomModelNames<TDefs>,
235
- ...args: InferConfig<TFactory> extends undefined
236
- ? []
237
- : [config?: InferConfig<TFactory>]
238
- ) => InferAdapterReturn<TFactory> {
250
+ >(factory: TFactory, _customModels: TDefs): ExtendedFactory<TFactory, TDefs>
251
+ // The implementation signature stays at the honest `AnyAdapterFactory` width;
252
+ // the overload above performs the deliberate model-union widening.
253
+ export function extendAdapter(
254
+ factory: AnyAdapterFactory,
255
+ _customModels: ReadonlyArray<ExtendedModelDef>,
256
+ ): AnyAdapterFactory {
239
257
  // At runtime, we simply pass through to the original factory.
240
258
  // The _customModels parameter is only used for type inference.
241
259
  // No runtime validation - users are trusted to pass valid model names.
242
- return factory as any
260
+ return factory
243
261
  }
package/src/index.ts CHANGED
@@ -52,6 +52,16 @@ export {
52
52
  type InferToolOutput,
53
53
  } from './activities/chat/tools/tool-definition'
54
54
 
55
+ // MCP chat option types
56
+ export type {
57
+ MCPToolSource,
58
+ ChatMCPOptions,
59
+ MCPConnectionPolicy,
60
+ } from './activities/chat/mcp/types'
61
+
62
+ // MCP error classes (value exports — usable with instanceof)
63
+ export { MCPDuplicateToolNameError } from './activities/chat/mcp/manager'
64
+
55
65
  // Schema conversion (Standard JSON Schema compliant)
56
66
  export {
57
67
  convertSchemaToJsonSchema,
package/src/types.ts CHANGED
@@ -490,6 +490,12 @@ export type ToolExecutionContext<TContext = unknown> =
490
490
  RuntimeContextField<TContext> & {
491
491
  /** The ID of the tool call being executed */
492
492
  toolCallId?: string
493
+ /**
494
+ * Abort signal for the current chat run. Aborts when the run's
495
+ * `abortController` fires (or middleware aborts). Long-running tools —
496
+ * e.g. MCP `callTool` — should forward this to cancel in-flight work.
497
+ */
498
+ abortSignal?: AbortSignal
493
499
  /**
494
500
  * Emit a custom event during tool execution.
495
501
  * Events are streamed to the client in real-time as AG-UI CUSTOM events.