@strav/brain 1.0.0-alpha.16 → 1.0.0-alpha.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 (42) hide show
  1. package/package.json +4 -2
  2. package/src/agent.ts +34 -5
  3. package/src/agent_generate_result.ts +2 -0
  4. package/src/agent_result.ts +7 -0
  5. package/src/agent_runner.ts +134 -15
  6. package/src/agent_stream_event.ts +100 -0
  7. package/src/brain_config.ts +91 -1
  8. package/src/brain_manager.ts +287 -6
  9. package/src/brain_provider.ts +25 -1
  10. package/src/index.ts +37 -2
  11. package/src/mcp/client.ts +99 -13
  12. package/src/mcp/index.ts +7 -0
  13. package/src/mcp/oauth.ts +227 -0
  14. package/src/mcp/pool.ts +106 -0
  15. package/src/mcp/resolve_mcp_tools.ts +31 -9
  16. package/src/mcp_server.ts +16 -0
  17. package/src/persistence/brain_message.ts +34 -0
  18. package/src/persistence/brain_message_repository.ts +106 -0
  19. package/src/persistence/brain_store.ts +166 -0
  20. package/src/persistence/brain_suspended_run.ts +30 -0
  21. package/src/persistence/brain_suspended_run_repository.ts +68 -0
  22. package/src/persistence/brain_thread.ts +30 -0
  23. package/src/persistence/brain_thread_repository.ts +65 -0
  24. package/src/persistence/database_brain_store.ts +190 -0
  25. package/src/persistence/index.ts +48 -0
  26. package/src/persistence/schema/brain_message_schema.ts +61 -0
  27. package/src/persistence/schema/brain_suspended_run_schema.ts +58 -0
  28. package/src/persistence/schema/brain_thread_schema.ts +50 -0
  29. package/src/persistence/schema/index.ts +3 -0
  30. package/src/provider.ts +145 -1
  31. package/src/providers/anthropic_provider.ts +723 -38
  32. package/src/providers/deepseek_provider.ts +117 -0
  33. package/src/providers/gemini_provider.ts +625 -33
  34. package/src/providers/ollama_provider.ts +86 -0
  35. package/src/providers/openai_compat_provider.ts +616 -0
  36. package/src/providers/openai_provider.ts +801 -43
  37. package/src/providers/openai_responses_provider.ts +1015 -0
  38. package/src/suspended_run.ts +153 -0
  39. package/src/thread.ts +40 -1
  40. package/src/tool.ts +7 -0
  41. package/src/tool_runner.ts +81 -0
  42. package/src/types.ts +343 -0
@@ -21,24 +21,36 @@
21
21
 
22
22
  import type { Agent } from './agent.ts'
23
23
  import type { AgentResult } from './agent_result.ts'
24
+ import type { AgentStreamEvent } from './agent_stream_event.ts'
24
25
  import type { MCPServer } from './mcp_server.ts'
26
+ import type { AgentGenerateResult } from './agent_generate_result.ts'
25
27
  import type { OutputSchema } from './output_schema.ts'
26
28
  import { AgentRunner } from './agent_runner.ts'
27
29
  import { BrainError } from './brain_error.ts'
28
30
  import type { ModelTier } from './types.ts'
29
31
  import type {
32
+ AudioSource,
30
33
  ChatOptions,
31
34
  ChatResult,
35
+ EmbedOptions,
36
+ EmbedResult,
32
37
  GenerateResult,
33
38
  Message,
34
39
  StreamEvent,
40
+ TranscribeOptions,
41
+ TranscribeResult,
35
42
  } from './types.ts'
36
- import type { Provider, RunWithToolsOptions } from './provider.ts'
43
+ import type {
44
+ Provider,
45
+ RunWithToolsOptions,
46
+ RunWithToolsOptionsWithSuspend,
47
+ } from './provider.ts'
48
+ import { appendResumeResults, type SuspendedRun, type SuspendedState, type ToolResultInput } from './suspended_run.ts'
37
49
  import type { Tool } from './tool.ts'
38
50
  import { DEFAULT_TIERS } from './brain_config.ts'
39
51
 
40
52
  /** Container-aware Agent constructor resolver — `BrainProvider` installs one wired to `app.resolve(...)`. */
41
- export type AgentResolver = <A extends Agent>(cls: new (...args: never[]) => A) => A
53
+ export type AgentResolver = <A extends Agent<unknown>>(cls: new (...args: never[]) => A) => A
42
54
 
43
55
  export interface BrainManagerOptions {
44
56
  /** Name of the default provider — must exist in `providers`. */
@@ -145,11 +157,21 @@ export class BrainManager {
145
157
  * implement `runWithTools` (V1: OpenAI / Gemini / DeepSeek providers
146
158
  * don't yet — only `AnthropicProvider`).
147
159
  */
160
+ runTools(
161
+ input: string | readonly Message[],
162
+ tools: readonly Tool[],
163
+ options: RunWithToolsOptionsWithSuspend,
164
+ ): Promise<AgentResult | SuspendedRun>
165
+ runTools(
166
+ input: string | readonly Message[],
167
+ tools: readonly Tool[],
168
+ options?: RunWithToolsOptions,
169
+ ): Promise<AgentResult>
148
170
  async runTools(
149
171
  input: string | readonly Message[],
150
172
  tools: readonly Tool[],
151
173
  options: RunWithToolsOptions = {},
152
- ): Promise<AgentResult> {
174
+ ): Promise<AgentResult | SuspendedRun> {
153
175
  const provider = this.provider(options.provider)
154
176
  if (!provider.runWithTools) {
155
177
  throw new BrainError(
@@ -168,6 +190,135 @@ export class BrainManager {
168
190
  return provider.runWithTools(messages, tools, resolved)
169
191
  }
170
192
 
193
+ /**
194
+ * Resume a previously-suspended tool-use loop. Takes the
195
+ * `SuspendedRun.state` snapshot plus the results the integrator
196
+ * gathered for each `pendingToolCalls` entry; appends a `tool_result`
197
+ * block per entry; re-enters `runTools` so the model can continue
198
+ * (potentially suspending again on the next tool).
199
+ *
200
+ * Mid-batch invariant: every pending call MUST get a result —
201
+ * otherwise the provider rejects the next request because the
202
+ * assistant turn's `tool_use` blocks are no longer balanced.
203
+ * `resumeTools` throws `BrainError` when results are missing.
204
+ *
205
+ * The `previousResponseId` carried on the snapshot (when the
206
+ * provider supports stateful conversations) is threaded back via
207
+ * `options.previousResponseId` automatically — per-call
208
+ * `options.previousResponseId` wins if supplied explicitly.
209
+ */
210
+ async resumeTools(
211
+ state: SuspendedState,
212
+ results: readonly ToolResultInput[],
213
+ tools: readonly Tool[],
214
+ options: RunWithToolsOptions = {},
215
+ ): Promise<AgentResult | SuspendedRun> {
216
+ const resumed = appendResumeResults(state, results)
217
+ const merged: RunWithToolsOptions = { ...options }
218
+ if (merged.previousResponseId === undefined && state.responseId !== undefined) {
219
+ merged.previousResponseId = state.responseId
220
+ }
221
+ const out = await this.runTools(
222
+ resumed,
223
+ tools,
224
+ merged as RunWithToolsOptionsWithSuspend,
225
+ )
226
+ return mergeResumeCounters(out, state)
227
+ }
228
+
229
+ /**
230
+ * Streaming variant of `generateWithTools`. Yields
231
+ * `AgentStreamEvent<T>`s as the loop progresses; the terminal
232
+ * `stop` event carries the parsed value + raw JSON text. Throws
233
+ * `BrainError` when the provider lacks
234
+ * `streamWithToolsAndSchema` (V1: all three providers
235
+ * implement it).
236
+ */
237
+ streamGenerateWithTools<T>(
238
+ input: string | readonly Message[],
239
+ schema: OutputSchema<T>,
240
+ tools: readonly Tool[],
241
+ options: RunWithToolsOptions = {},
242
+ ): AsyncIterable<AgentStreamEvent<T>> {
243
+ rejectShouldSuspend(options, 'streamGenerateWithTools')
244
+ const provider = this.provider(options.provider)
245
+ if (!provider.streamWithToolsAndSchema) {
246
+ throw new BrainError(
247
+ `BrainManager.streamGenerateWithTools: provider "${provider.name}" does not implement streamWithToolsAndSchema.`,
248
+ { context: { provider: provider.name } },
249
+ )
250
+ }
251
+ const messages = normalizeInput(input)
252
+ const resolved = this.applyDefaults(options) as RunWithToolsOptions
253
+ if (resolved.mcpServers === undefined && this.defaultMcpServers.length > 0) {
254
+ resolved.mcpServers = this.defaultMcpServers
255
+ }
256
+ return provider.streamWithToolsAndSchema<T>(messages, tools, schema, resolved)
257
+ }
258
+
259
+ /**
260
+ * Tool-loop + structured output combined. Runs the agentic loop
261
+ * with the supplied `tools` while pinning the output to `schema`
262
+ * on every turn; returns the parsed value when the model finally
263
+ * answers without calling a tool. MCP defaults + tier resolution
264
+ * + provider routing match `runTools` / `generate`.
265
+ *
266
+ * Throws `BrainError` when the chosen provider doesn't implement
267
+ * `runWithToolsAndSchema`. V1: all three providers do.
268
+ */
269
+ async generateWithTools<T>(
270
+ input: string | readonly Message[],
271
+ schema: OutputSchema<T>,
272
+ tools: readonly Tool[],
273
+ options: RunWithToolsOptions = {},
274
+ ): Promise<AgentGenerateResult<T>> {
275
+ rejectShouldSuspend(options, 'generateWithTools')
276
+ const provider = this.provider(options.provider)
277
+ if (!provider.runWithToolsAndSchema) {
278
+ throw new BrainError(
279
+ `BrainManager.generateWithTools: provider "${provider.name}" does not implement runWithToolsAndSchema.`,
280
+ { context: { provider: provider.name } },
281
+ )
282
+ }
283
+ const messages = normalizeInput(input)
284
+ const resolved = this.applyDefaults(options) as RunWithToolsOptions
285
+ if (resolved.mcpServers === undefined && this.defaultMcpServers.length > 0) {
286
+ resolved.mcpServers = this.defaultMcpServers
287
+ }
288
+ return provider.runWithToolsAndSchema<T>(messages, tools, schema, resolved)
289
+ }
290
+
291
+ /**
292
+ * Streaming variant of `runTools`. Yields `AgentStreamEvent`s
293
+ * as the agentic loop progresses — text deltas during model
294
+ * turns, `tool_use` / `tool_result` boundaries around tool
295
+ * execution, `iteration_start` / `iteration_end` per round, a
296
+ * terminal `stop` with the full trace + usage.
297
+ *
298
+ * Throws `BrainError` when the configured provider doesn't
299
+ * implement `streamWithTools`.
300
+ */
301
+ streamTools(
302
+ input: string | readonly Message[],
303
+ tools: readonly Tool[],
304
+ options: RunWithToolsOptions = {},
305
+ ): AsyncIterable<AgentStreamEvent> {
306
+ rejectShouldSuspend(options, 'streamTools')
307
+ const provider = this.provider(options.provider)
308
+ if (!provider.streamWithTools) {
309
+ throw new BrainError(
310
+ `BrainManager.streamTools: provider "${provider.name}" does not implement streamWithTools.`,
311
+ { context: { provider: provider.name } },
312
+ )
313
+ }
314
+ const messages = normalizeInput(input)
315
+ const resolved = this.applyDefaults(options) as RunWithToolsOptions
316
+ if (resolved.mcpServers === undefined && this.defaultMcpServers.length > 0) {
317
+ resolved.mcpServers = this.defaultMcpServers
318
+ }
319
+ return provider.streamWithTools(messages, tools, resolved)
320
+ }
321
+
171
322
  /**
172
323
  * Structured output. Sends `input` to the configured (or
173
324
  * `options.provider`-overridden) provider with the JSON-Schema
@@ -194,21 +345,88 @@ export class BrainManager {
194
345
  return provider.generate<T>(messages, schema, resolved)
195
346
  }
196
347
 
348
+ /**
349
+ * Turn one or more text inputs into embedding vectors. Accepts
350
+ * either a single string (returns one vector) or an array
351
+ * (batch — returns one vector per input in the same order).
352
+ *
353
+ * Throws `BrainError` when the configured (or
354
+ * `options.provider`-overridden) provider doesn't implement
355
+ * `embed`. V1: OpenAI, Gemini, Ollama support it; Anthropic +
356
+ * DeepSeek throw with a clear "route to a different provider"
357
+ * message.
358
+ */
359
+ async embed(
360
+ input: string | readonly string[],
361
+ options: EmbedOptions = {},
362
+ ): Promise<EmbedResult> {
363
+ const provider = this.provider(options.provider)
364
+ if (!provider.embed) {
365
+ throw new BrainError(
366
+ `BrainManager.embed: provider "${provider.name}" does not implement embed. Route to a provider with an embeddings API (V1: OpenAI / Gemini / Ollama).`,
367
+ { context: { provider: provider.name } },
368
+ )
369
+ }
370
+ const texts = typeof input === 'string' ? [input] : input
371
+ return provider.embed(texts, options)
372
+ }
373
+
374
+ /**
375
+ * Transcribe one audio clip to text. Complements `AudioBlock`
376
+ * (which sends audio + a text prompt together to a multimodal
377
+ * chat model) by exposing the dedicated transcription endpoint
378
+ * where the provider has one. Apps that already have an
379
+ * `AudioBlock` can pass its `source` directly.
380
+ *
381
+ * Throws `BrainError` when the configured (or
382
+ * `options.provider`-overridden) provider doesn't implement
383
+ * `transcribe`. V1: OpenAI / Ollama (Whisper / gpt-4o-transcribe
384
+ * / local) and Gemini (chat-wrap fallback); Anthropic +
385
+ * DeepSeek throw.
386
+ */
387
+ async transcribe(
388
+ audio: AudioSource,
389
+ options: TranscribeOptions = {},
390
+ ): Promise<TranscribeResult> {
391
+ const provider = this.provider(options.provider)
392
+ if (!provider.transcribe) {
393
+ throw new BrainError(
394
+ `BrainManager.transcribe: provider "${provider.name}" does not implement transcribe. Route to a provider with audio support (V1: OpenAI / Ollama / Gemini).`,
395
+ { context: { provider: provider.name } },
396
+ )
397
+ }
398
+ return provider.transcribe(audio, options)
399
+ }
400
+
197
401
  /**
198
402
  * Resolve an `Agent` subclass from the container and return an
199
403
  * `AgentRunner` ready to receive `input(...)` and `run()`. Apps
200
404
  * `@inject()`-decorate their Agent subclass so constructor
201
405
  * injection of dependencies (Repositories, services, etc.) flows
202
406
  * through normally.
407
+ *
408
+ * When the agent subclass extends `Agent<T>` for some `T` and
409
+ * declares `outputSchema`, the returned runner is typed as
410
+ * `AgentRunner<T>` and the schema is pre-applied — `.run()`
411
+ * returns `AgentGenerateResult<T>` without a per-call
412
+ * `.output(schema)`. Apps can still chain `.output(otherSchema)`
413
+ * to override.
203
414
  */
204
- agent<A extends Agent>(AgentClass: new (...args: never[]) => A, instance?: A): AgentRunner {
415
+ agent<T = never>(
416
+ AgentClass: new (...args: never[]) => Agent<T>,
417
+ instance?: Agent<T>,
418
+ ): AgentRunner<T> {
205
419
  const agent = instance ?? this.resolveAgent(AgentClass)
206
- return new AgentRunner(this, agent)
420
+ const runner = new AgentRunner<T>(this, agent)
421
+ if (agent.outputSchema !== undefined) {
422
+ return runner.output(agent.outputSchema)
423
+ }
424
+ return runner
207
425
  }
208
426
 
209
427
  // ─── Internal ────────────────────────────────────────────────────────────
210
428
 
211
- private resolveAgent<A extends Agent>(AgentClass: new (...args: never[]) => A): A {
429
+ private resolveAgent<A extends Agent<unknown>>(AgentClass: new (...args: never[]) => A): A {
212
430
  if (this.agentResolver) return this.agentResolver(AgentClass)
213
431
  // Fallback: assume the Agent class is constructible without args.
214
432
  // Apps that need DI on the agent register a resolver via
@@ -247,3 +465,66 @@ function normalizeInput(input: string | readonly Message[]): readonly Message[]
247
465
  }
248
466
  return input
249
467
  }
468
+
469
+ /**
470
+ * V1 scope guard. `shouldSuspend` is wired only into the non-
471
+ * streaming `runWithTools` loop; the streaming and schema variants
472
+ * don't yet model pause / resume, so silently ignoring would be
473
+ * worse than throwing. Apps that need both should run tools first
474
+ * (suspending as needed), then call `generate` for the structured
475
+ * summary in a separate step.
476
+ */
477
+ /**
478
+ * Carry forward the pre-suspension iteration count + token usage so
479
+ * `result.iterations` / `result.usage` reflect the full run, not
480
+ * just the post-resume portion. When the resumed call suspends
481
+ * again, the new state's iterations + usage also get the carry-
482
+ * forward so apps see a running total across an arbitrary number
483
+ * of suspension cycles.
484
+ */
485
+ function mergeResumeCounters(
486
+ out: AgentResult | SuspendedRun,
487
+ state: SuspendedState,
488
+ ): AgentResult | SuspendedRun {
489
+ // +1 accounts for the suspended round itself — at suspension time
490
+ // the loop hadn't yet incremented `iterations` (we paused mid-
491
+ // batch, before tool execution). Supplying results to resume
492
+ // effectively completes that round.
493
+ const carryIter = state.iterations + 1
494
+ if ('status' in out) {
495
+ return {
496
+ ...out,
497
+ state: {
498
+ ...out.state,
499
+ iterations: out.state.iterations + carryIter,
500
+ usage: addUsage(out.state.usage, state.usage),
501
+ },
502
+ }
503
+ }
504
+ return {
505
+ ...out,
506
+ iterations: out.iterations + carryIter,
507
+ usage: addUsage(out.usage, state.usage),
508
+ }
509
+ }
510
+
511
+ function addUsage(
512
+ a: SuspendedState['usage'],
513
+ b: SuspendedState['usage'],
514
+ ): SuspendedState['usage'] {
515
+ return {
516
+ inputTokens: a.inputTokens + b.inputTokens,
517
+ outputTokens: a.outputTokens + b.outputTokens,
518
+ cacheReadTokens: a.cacheReadTokens + b.cacheReadTokens,
519
+ cacheCreationTokens: a.cacheCreationTokens + b.cacheCreationTokens,
520
+ }
521
+ }
522
+
523
+ function rejectShouldSuspend(options: RunWithToolsOptions, entry: string): void {
524
+ if (options.shouldSuspend !== undefined) {
525
+ throw new BrainError(
526
+ `BrainManager.${entry}: \`shouldSuspend\` is only supported on \`runTools\` (the non-streaming + no-schema entrypoint) in V1. Run tools first with suspension, then call \`generate\` for the structured summary as a separate step.`,
527
+ { context: { entry } },
528
+ )
529
+ }
530
+ }
@@ -28,8 +28,11 @@ import { type Application, ConfigError, ConfigRepository, ServiceProvider } from
28
28
  import { BrainManager } from './brain_manager.ts'
29
29
  import type { BrainConfigShape, ProviderConfig } from './brain_config.ts'
30
30
  import { AnthropicProvider } from './providers/anthropic_provider.ts'
31
+ import { DeepSeekProvider } from './providers/deepseek_provider.ts'
31
32
  import { GeminiProvider } from './providers/gemini_provider.ts'
33
+ import { OllamaProvider } from './providers/ollama_provider.ts'
32
34
  import { OpenAIProvider } from './providers/openai_provider.ts'
35
+ import { OpenAIResponsesProvider } from './providers/openai_responses_provider.ts'
33
36
  import type { Provider } from './provider.ts'
34
37
 
35
38
  export class BrainProvider extends ServiceProvider {
@@ -102,6 +105,13 @@ function buildProvider(name: string, config: ProviderConfig): Provider {
102
105
  )
103
106
  }
104
107
  return new OpenAIProvider(name, config)
108
+ case 'openai-responses':
109
+ if (!config.apiKey) {
110
+ throw new ConfigError(
111
+ `BrainProvider: openai-responses provider "${name}" is missing apiKey. Source from env('OPENAI_API_KEY').`,
112
+ )
113
+ }
114
+ return new OpenAIResponsesProvider(name, config)
105
115
  case 'google':
106
116
  if (!config.apiKey) {
107
117
  throw new ConfigError(
@@ -109,10 +119,24 @@ function buildProvider(name: string, config: ProviderConfig): Provider {
109
119
  )
110
120
  }
111
121
  return new GeminiProvider(name, config)
122
+ case 'deepseek':
123
+ if (!config.apiKey) {
124
+ throw new ConfigError(
125
+ `BrainProvider: deepseek provider "${name}" is missing apiKey. Source from env('DEEPSEEK_API_KEY').`,
126
+ )
127
+ }
128
+ return new DeepSeekProvider(name, config)
129
+ case 'ollama':
130
+ if (!config.defaultModel) {
131
+ throw new ConfigError(
132
+ `BrainProvider: ollama provider "${name}" is missing defaultModel. Ollama models are user-installed — pick one you've pulled (e.g. 'llama3.2').`,
133
+ )
134
+ }
135
+ return new OllamaProvider(name, config)
112
136
  default: {
113
137
  const exhaustiveCheck: never = config
114
138
  throw new ConfigError(
115
- `BrainProvider: unknown driver for provider "${name}". Known drivers: anthropic, openai, google.`,
139
+ `BrainProvider: unknown driver for provider "${name}". Known drivers: anthropic, openai, openai-responses, google, deepseek, ollama.`,
116
140
  )
117
141
  // (unreachable — kept for the exhaustive check to fire when a new driver lands)
118
142
  // biome-ignore lint/correctness/noUnreachable: kept for the exhaustive-check above
package/src/index.ts CHANGED
@@ -10,15 +10,23 @@
10
10
  export { Agent } from './agent.ts'
11
11
  export type { AgentGenerateResult } from './agent_generate_result.ts'
12
12
  export type { AgentResult } from './agent_result.ts'
13
- export { AgentRunner, type AgentRunResult } from './agent_runner.ts'
13
+ export {
14
+ AgentRunner,
15
+ type AgentRunMaybeSuspended,
16
+ type AgentRunResult,
17
+ } from './agent_runner.ts'
18
+ export type { AgentStreamEvent } from './agent_stream_event.ts'
14
19
  export {
15
20
  type AnthropicProviderConfig,
16
21
  type BrainCacheConfig,
17
22
  type BrainConfigShape,
23
+ type DeepSeekProviderConfig,
18
24
  DEFAULT_MODEL,
19
25
  DEFAULT_TIERS,
20
26
  type GeminiProviderConfig,
27
+ type OllamaProviderConfig,
21
28
  type OpenAIProviderConfig,
29
+ type OpenAIResponsesProviderConfig,
22
30
  type ProviderConfig,
23
31
  } from './brain_config.ts'
24
32
  export { BrainError } from './brain_error.ts'
@@ -29,12 +37,28 @@ export {
29
37
  } from './brain_manager.ts'
30
38
  export { BrainProvider } from './brain_provider.ts'
31
39
  export { defineTool, type DefineToolSpec } from './define_tool.ts'
40
+ export { MCPClientPool, type MCPClientFactory } from './mcp/pool.ts'
32
41
  export type { MCPServer, MCPServerToolConfig } from './mcp_server.ts'
33
42
  export type { OutputSchema } from './output_schema.ts'
34
43
  export { AnthropicProvider } from './providers/anthropic_provider.ts'
44
+ export { DeepSeekProvider } from './providers/deepseek_provider.ts'
35
45
  export { GeminiProvider } from './providers/gemini_provider.ts'
46
+ export { OllamaProvider } from './providers/ollama_provider.ts'
47
+ export { OpenAICompatProvider } from './providers/openai_compat_provider.ts'
36
48
  export { OpenAIProvider } from './providers/openai_provider.ts'
37
- export type { Provider, RunWithToolsOptions } from './provider.ts'
49
+ export { OpenAIResponsesProvider } from './providers/openai_responses_provider.ts'
50
+ export type {
51
+ Provider,
52
+ RunWithToolsOptions,
53
+ RunWithToolsOptionsWithSuspend,
54
+ } from './provider.ts'
55
+ export {
56
+ appendResumeResults,
57
+ isSuspended,
58
+ type SuspendedRun,
59
+ type SuspendedState,
60
+ type ToolResultInput,
61
+ } from './suspended_run.ts'
38
62
  export { Thread, type ThreadOptions, type ThreadState } from './thread.ts'
39
63
  export type { Tool, ToolContext } from './tool.ts'
40
64
  export { ToolExecutionError } from './tool_execution_error.ts'
@@ -42,15 +66,26 @@ export type {
42
66
  ChatOptions,
43
67
  ChatResult,
44
68
  ChatUsage,
69
+ CompactConfig,
70
+ CompactionBlock,
45
71
  ContentBlock,
72
+ AudioBlock,
73
+ AudioSource,
74
+ DocumentBlock,
75
+ EmbedOptions,
76
+ EmbedResult,
46
77
  GenerateResult,
78
+ ImageBlock,
47
79
  MCPToolResultBlock,
48
80
  MCPToolUseBlock,
49
81
  Message,
50
82
  ModelTier,
83
+ ServerTool,
51
84
  StreamEvent,
52
85
  SystemPrompt,
53
86
  TextBlock,
54
87
  ToolResultBlock,
55
88
  ToolUseBlock,
89
+ TranscribeOptions,
90
+ TranscribeResult,
56
91
  } from './types.ts'
package/src/mcp/client.ts CHANGED
@@ -16,10 +16,16 @@
16
16
  * await client.close()
17
17
  *
18
18
  * Authentication:
19
- * `MCPServer.authorizationToken` is forwarded as
20
- * `Authorization: Bearer <token>`. OAuth-flow servers need
21
- * out-of-band token exchange same constraint as the server-side
22
- * path. Full OAuth handshake is a later slice.
19
+ * - `MCPServer.authorizationToken` static bearer; fine for
20
+ * self-hosted servers where the app controls the token.
21
+ * - `MCPServer.oauth` drives the authorization-code-with-PKCE
22
+ * flow against the server's OAuth endpoints. On
23
+ * `connect()` against an un-authorized server,
24
+ * `MCPAuthRequiredError` is thrown carrying the URL the user
25
+ * should be redirected to. After the user authorizes and is
26
+ * redirected back, the app's callback handler calls
27
+ * `completeAuthorization(code)` to finish the exchange.
28
+ * The two are mutually exclusive — passing both throws.
23
29
  *
24
30
  * Transport:
25
31
  * V1 only does Streamable HTTP — the current MCP transport. Legacy
@@ -31,8 +37,10 @@
31
37
 
32
38
  import { Client } from '@modelcontextprotocol/sdk/client/index.js'
33
39
  import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
40
+ import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js'
34
41
  import { BrainError } from '../brain_error.ts'
35
42
  import type { MCPServer } from '../mcp_server.ts'
43
+ import { MCPAuthRequiredError, StoreBackedOAuthProvider } from './oauth.ts'
36
44
 
37
45
  /** Result of a single MCP tool invocation, as returned by `tools/call`. */
38
46
  export interface MCPCallToolResult {
@@ -58,8 +66,25 @@ export class MCPClient {
58
66
  readonly server: MCPServer
59
67
  private readonly _client: Client
60
68
  private _connected = false
69
+ /**
70
+ * In-flight connect promise — set on the first concurrent
71
+ * `connect()` and cleared on settle. Subsequent callers that
72
+ * race against the first one await the same promise instead of
73
+ * each kicking off their own transport handshake. Necessary for
74
+ * pooled clients: a fresh `borrow()` followed by parallel
75
+ * `listTools()` + `callTool()` calls both hit the same connect.
76
+ */
77
+ private _connecting: Promise<void> | undefined
78
+ private _transport: StreamableHTTPClientTransport | undefined
79
+ private _authProvider: StoreBackedOAuthProvider | undefined
61
80
 
62
81
  constructor(server: MCPServer, options: MCPClientOptions = {}) {
82
+ if (server.authorizationToken !== undefined && server.oauth !== undefined) {
83
+ throw new BrainError(
84
+ `MCPClient(${server.name}): \`authorizationToken\` and \`oauth\` are mutually exclusive — set one.`,
85
+ { context: { server: server.name } },
86
+ )
87
+ }
63
88
  this.server = server
64
89
  this._client =
65
90
  options.client ??
@@ -71,11 +96,26 @@ export class MCPClient {
71
96
 
72
97
  async connect(): Promise<void> {
73
98
  if (this._connected) return
99
+ if (this._connecting) return this._connecting
100
+ this._connecting = this._doConnect().finally(() => {
101
+ this._connecting = undefined
102
+ })
103
+ return this._connecting
104
+ }
105
+
106
+ private async _doConnect(): Promise<void> {
74
107
  const transport = this._buildTransport()
108
+ this._transport = transport
75
109
  try {
76
110
  await this._client.connect(transport)
77
111
  this._connected = true
78
112
  } catch (cause) {
113
+ if (cause instanceof UnauthorizedError && this._authProvider?.capturedAuthorizationUrl) {
114
+ throw new MCPAuthRequiredError(
115
+ this.server.name,
116
+ this._authProvider.capturedAuthorizationUrl.toString(),
117
+ )
118
+ }
79
119
  throw new BrainError(
80
120
  `MCPClient(${this.server.name}): failed to connect to ${this.server.url}.`,
81
121
  { context: { server: this.server.name, url: this.server.url }, cause },
@@ -83,11 +123,44 @@ export class MCPClient {
83
123
  }
84
124
  }
85
125
 
86
- async listTools(): Promise<MCPToolDescriptor[]> {
126
+ /**
127
+ * Finish the OAuth authorization-code flow after the user
128
+ * authorized and was redirected back to the app's callback URL.
129
+ * Exchanges `code` for tokens, persists them via the configured
130
+ * `MCPOAuthStore`, then connects.
131
+ *
132
+ * Throws `BrainError` when called on a server without OAuth
133
+ * configured.
134
+ */
135
+ async completeAuthorization(code: string): Promise<void> {
136
+ if (this.server.oauth === undefined) {
137
+ throw new BrainError(
138
+ `MCPClient(${this.server.name}): completeAuthorization() called on a server without \`oauth\` configured.`,
139
+ { context: { server: this.server.name } },
140
+ )
141
+ }
142
+ if (this._transport === undefined) {
143
+ // A previous `connect()` attempt builds the transport + captures
144
+ // the auth URL on the provider. Apps that lost the transport
145
+ // reference (typical for stateless callback handlers) can
146
+ // construct a fresh transport here — the store carries every
147
+ // bit of state the exchange needs.
148
+ this._transport = this._buildTransport()
149
+ }
150
+ await this._transport.finishAuth(code)
151
+ // Now that tokens are saved, re-attempt the connection.
152
+ this._connected = false
153
+ await this.connect()
154
+ }
155
+
156
+ async listTools(opts: { signal?: AbortSignal } = {}): Promise<MCPToolDescriptor[]> {
87
157
  await this.connect()
88
158
  let response: Awaited<ReturnType<Client['listTools']>>
89
159
  try {
90
- response = await this._client.listTools()
160
+ response = await this._client.listTools(
161
+ undefined,
162
+ opts.signal !== undefined ? { signal: opts.signal } : undefined,
163
+ )
91
164
  } catch (cause) {
92
165
  throw new BrainError(
93
166
  `MCPClient(${this.server.name}): tools/list failed.`,
@@ -101,14 +174,22 @@ export class MCPClient {
101
174
  }))
102
175
  }
103
176
 
104
- async callTool(name: string, input: unknown): Promise<MCPCallToolResult> {
177
+ async callTool(
178
+ name: string,
179
+ input: unknown,
180
+ opts: { signal?: AbortSignal } = {},
181
+ ): Promise<MCPCallToolResult> {
105
182
  await this.connect()
106
183
  let response: Awaited<ReturnType<Client['callTool']>>
107
184
  try {
108
- response = await this._client.callTool({
109
- name,
110
- arguments: (input ?? {}) as Record<string, unknown>,
111
- })
185
+ response = await this._client.callTool(
186
+ {
187
+ name,
188
+ arguments: (input ?? {}) as Record<string, unknown>,
189
+ },
190
+ undefined,
191
+ opts.signal !== undefined ? { signal: opts.signal } : undefined,
192
+ )
112
193
  } catch (cause) {
113
194
  throw new BrainError(
114
195
  `MCPClient(${this.server.name}): tools/call ${name} failed.`,
@@ -135,9 +216,14 @@ export class MCPClient {
135
216
  if (this.server.authorizationToken !== undefined) {
136
217
  headers.Authorization = `Bearer ${this.server.authorizationToken}`
137
218
  }
138
- return new StreamableHTTPClientTransport(new URL(this.server.url), {
219
+ const transportOpts: ConstructorParameters<typeof StreamableHTTPClientTransport>[1] = {
139
220
  requestInit: { headers },
140
- })
221
+ }
222
+ if (this.server.oauth !== undefined) {
223
+ this._authProvider = new StoreBackedOAuthProvider(this.server.oauth)
224
+ transportOpts.authProvider = this._authProvider
225
+ }
226
+ return new StreamableHTTPClientTransport(new URL(this.server.url), transportOpts)
141
227
  }
142
228
  }
143
229
 
package/src/mcp/index.ts CHANGED
@@ -9,6 +9,13 @@ export {
9
9
  type MCPClientOptions,
10
10
  type MCPToolDescriptor,
11
11
  } from './client.ts'
12
+ export {
13
+ MCPAuthRequiredError,
14
+ type MCPOAuthConfig,
15
+ type MCPOAuthStore,
16
+ MemoryOAuthStore,
17
+ } from './oauth.ts'
18
+ export { MCPClientPool, type MCPClientFactory } from './pool.ts'
12
19
  export {
13
20
  resolveMcpTools,
14
21
  type ResolveMcpToolsOptions,