@strav/brain 1.0.0-alpha.15 → 1.0.0-alpha.17
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.
- package/package.json +2 -2
- package/src/agent.ts +34 -5
- package/src/agent_generate_result.ts +30 -0
- package/src/agent_runner.ts +140 -14
- package/src/agent_stream_event.ts +100 -0
- package/src/brain_config.ts +91 -1
- package/src/brain_manager.ts +168 -4
- package/src/brain_provider.ts +25 -1
- package/src/index.ts +19 -1
- package/src/mcp/client.ts +82 -13
- package/src/mcp/index.ts +6 -0
- package/src/mcp/oauth.ts +227 -0
- package/src/mcp/resolve_mcp_tools.ts +6 -2
- package/src/mcp_server.ts +16 -0
- package/src/provider.ts +109 -0
- package/src/providers/anthropic_provider.ts +596 -28
- package/src/providers/deepseek_provider.ts +117 -0
- package/src/providers/gemini_provider.ts +590 -21
- package/src/providers/ollama_provider.ts +86 -0
- package/src/providers/openai_compat_provider.ts +187 -0
- package/src/providers/openai_provider.ts +735 -32
- package/src/providers/openai_responses_provider.ts +700 -0
- package/src/tool.ts +7 -0
- package/src/tool_runner.ts +81 -0
- package/src/types.ts +233 -0
package/src/brain_manager.ts
CHANGED
|
@@ -21,24 +21,31 @@
|
|
|
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
43
|
import type { Provider, RunWithToolsOptions } from './provider.ts'
|
|
37
44
|
import type { Tool } from './tool.ts'
|
|
38
45
|
import { DEFAULT_TIERS } from './brain_config.ts'
|
|
39
46
|
|
|
40
47
|
/** Container-aware Agent constructor resolver — `BrainProvider` installs one wired to `app.resolve(...)`. */
|
|
41
|
-
export type AgentResolver = <A extends Agent
|
|
48
|
+
export type AgentResolver = <A extends Agent<unknown>>(cls: new (...args: never[]) => A) => A
|
|
42
49
|
|
|
43
50
|
export interface BrainManagerOptions {
|
|
44
51
|
/** Name of the default provider — must exist in `providers`. */
|
|
@@ -168,6 +175,96 @@ export class BrainManager {
|
|
|
168
175
|
return provider.runWithTools(messages, tools, resolved)
|
|
169
176
|
}
|
|
170
177
|
|
|
178
|
+
/**
|
|
179
|
+
* Streaming variant of `generateWithTools`. Yields
|
|
180
|
+
* `AgentStreamEvent<T>`s as the loop progresses; the terminal
|
|
181
|
+
* `stop` event carries the parsed value + raw JSON text. Throws
|
|
182
|
+
* `BrainError` when the provider lacks
|
|
183
|
+
* `streamWithToolsAndSchema` (V1: all three providers
|
|
184
|
+
* implement it).
|
|
185
|
+
*/
|
|
186
|
+
streamGenerateWithTools<T>(
|
|
187
|
+
input: string | readonly Message[],
|
|
188
|
+
schema: OutputSchema<T>,
|
|
189
|
+
tools: readonly Tool[],
|
|
190
|
+
options: RunWithToolsOptions = {},
|
|
191
|
+
): AsyncIterable<AgentStreamEvent<T>> {
|
|
192
|
+
const provider = this.provider(options.provider)
|
|
193
|
+
if (!provider.streamWithToolsAndSchema) {
|
|
194
|
+
throw new BrainError(
|
|
195
|
+
`BrainManager.streamGenerateWithTools: provider "${provider.name}" does not implement streamWithToolsAndSchema.`,
|
|
196
|
+
{ context: { provider: provider.name } },
|
|
197
|
+
)
|
|
198
|
+
}
|
|
199
|
+
const messages = normalizeInput(input)
|
|
200
|
+
const resolved = this.applyDefaults(options) as RunWithToolsOptions
|
|
201
|
+
if (resolved.mcpServers === undefined && this.defaultMcpServers.length > 0) {
|
|
202
|
+
resolved.mcpServers = this.defaultMcpServers
|
|
203
|
+
}
|
|
204
|
+
return provider.streamWithToolsAndSchema<T>(messages, tools, schema, resolved)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Tool-loop + structured output combined. Runs the agentic loop
|
|
209
|
+
* with the supplied `tools` while pinning the output to `schema`
|
|
210
|
+
* on every turn; returns the parsed value when the model finally
|
|
211
|
+
* answers without calling a tool. MCP defaults + tier resolution
|
|
212
|
+
* + provider routing match `runTools` / `generate`.
|
|
213
|
+
*
|
|
214
|
+
* Throws `BrainError` when the chosen provider doesn't implement
|
|
215
|
+
* `runWithToolsAndSchema`. V1: all three providers do.
|
|
216
|
+
*/
|
|
217
|
+
async generateWithTools<T>(
|
|
218
|
+
input: string | readonly Message[],
|
|
219
|
+
schema: OutputSchema<T>,
|
|
220
|
+
tools: readonly Tool[],
|
|
221
|
+
options: RunWithToolsOptions = {},
|
|
222
|
+
): Promise<AgentGenerateResult<T>> {
|
|
223
|
+
const provider = this.provider(options.provider)
|
|
224
|
+
if (!provider.runWithToolsAndSchema) {
|
|
225
|
+
throw new BrainError(
|
|
226
|
+
`BrainManager.generateWithTools: provider "${provider.name}" does not implement runWithToolsAndSchema.`,
|
|
227
|
+
{ context: { provider: provider.name } },
|
|
228
|
+
)
|
|
229
|
+
}
|
|
230
|
+
const messages = normalizeInput(input)
|
|
231
|
+
const resolved = this.applyDefaults(options) as RunWithToolsOptions
|
|
232
|
+
if (resolved.mcpServers === undefined && this.defaultMcpServers.length > 0) {
|
|
233
|
+
resolved.mcpServers = this.defaultMcpServers
|
|
234
|
+
}
|
|
235
|
+
return provider.runWithToolsAndSchema<T>(messages, tools, schema, resolved)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Streaming variant of `runTools`. Yields `AgentStreamEvent`s
|
|
240
|
+
* as the agentic loop progresses — text deltas during model
|
|
241
|
+
* turns, `tool_use` / `tool_result` boundaries around tool
|
|
242
|
+
* execution, `iteration_start` / `iteration_end` per round, a
|
|
243
|
+
* terminal `stop` with the full trace + usage.
|
|
244
|
+
*
|
|
245
|
+
* Throws `BrainError` when the configured provider doesn't
|
|
246
|
+
* implement `streamWithTools`.
|
|
247
|
+
*/
|
|
248
|
+
streamTools(
|
|
249
|
+
input: string | readonly Message[],
|
|
250
|
+
tools: readonly Tool[],
|
|
251
|
+
options: RunWithToolsOptions = {},
|
|
252
|
+
): AsyncIterable<AgentStreamEvent> {
|
|
253
|
+
const provider = this.provider(options.provider)
|
|
254
|
+
if (!provider.streamWithTools) {
|
|
255
|
+
throw new BrainError(
|
|
256
|
+
`BrainManager.streamTools: provider "${provider.name}" does not implement streamWithTools.`,
|
|
257
|
+
{ context: { provider: provider.name } },
|
|
258
|
+
)
|
|
259
|
+
}
|
|
260
|
+
const messages = normalizeInput(input)
|
|
261
|
+
const resolved = this.applyDefaults(options) as RunWithToolsOptions
|
|
262
|
+
if (resolved.mcpServers === undefined && this.defaultMcpServers.length > 0) {
|
|
263
|
+
resolved.mcpServers = this.defaultMcpServers
|
|
264
|
+
}
|
|
265
|
+
return provider.streamWithTools(messages, tools, resolved)
|
|
266
|
+
}
|
|
267
|
+
|
|
171
268
|
/**
|
|
172
269
|
* Structured output. Sends `input` to the configured (or
|
|
173
270
|
* `options.provider`-overridden) provider with the JSON-Schema
|
|
@@ -194,21 +291,88 @@ export class BrainManager {
|
|
|
194
291
|
return provider.generate<T>(messages, schema, resolved)
|
|
195
292
|
}
|
|
196
293
|
|
|
294
|
+
/**
|
|
295
|
+
* Turn one or more text inputs into embedding vectors. Accepts
|
|
296
|
+
* either a single string (returns one vector) or an array
|
|
297
|
+
* (batch — returns one vector per input in the same order).
|
|
298
|
+
*
|
|
299
|
+
* Throws `BrainError` when the configured (or
|
|
300
|
+
* `options.provider`-overridden) provider doesn't implement
|
|
301
|
+
* `embed`. V1: OpenAI, Gemini, Ollama support it; Anthropic +
|
|
302
|
+
* DeepSeek throw with a clear "route to a different provider"
|
|
303
|
+
* message.
|
|
304
|
+
*/
|
|
305
|
+
async embed(
|
|
306
|
+
input: string | readonly string[],
|
|
307
|
+
options: EmbedOptions = {},
|
|
308
|
+
): Promise<EmbedResult> {
|
|
309
|
+
const provider = this.provider(options.provider)
|
|
310
|
+
if (!provider.embed) {
|
|
311
|
+
throw new BrainError(
|
|
312
|
+
`BrainManager.embed: provider "${provider.name}" does not implement embed. Route to a provider with an embeddings API (V1: OpenAI / Gemini / Ollama).`,
|
|
313
|
+
{ context: { provider: provider.name } },
|
|
314
|
+
)
|
|
315
|
+
}
|
|
316
|
+
const texts = typeof input === 'string' ? [input] : input
|
|
317
|
+
return provider.embed(texts, options)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Transcribe one audio clip to text. Complements `AudioBlock`
|
|
322
|
+
* (which sends audio + a text prompt together to a multimodal
|
|
323
|
+
* chat model) by exposing the dedicated transcription endpoint
|
|
324
|
+
* where the provider has one. Apps that already have an
|
|
325
|
+
* `AudioBlock` can pass its `source` directly.
|
|
326
|
+
*
|
|
327
|
+
* Throws `BrainError` when the configured (or
|
|
328
|
+
* `options.provider`-overridden) provider doesn't implement
|
|
329
|
+
* `transcribe`. V1: OpenAI / Ollama (Whisper / gpt-4o-transcribe
|
|
330
|
+
* / local) and Gemini (chat-wrap fallback); Anthropic +
|
|
331
|
+
* DeepSeek throw.
|
|
332
|
+
*/
|
|
333
|
+
async transcribe(
|
|
334
|
+
audio: AudioSource,
|
|
335
|
+
options: TranscribeOptions = {},
|
|
336
|
+
): Promise<TranscribeResult> {
|
|
337
|
+
const provider = this.provider(options.provider)
|
|
338
|
+
if (!provider.transcribe) {
|
|
339
|
+
throw new BrainError(
|
|
340
|
+
`BrainManager.transcribe: provider "${provider.name}" does not implement transcribe. Route to a provider with audio support (V1: OpenAI / Ollama / Gemini).`,
|
|
341
|
+
{ context: { provider: provider.name } },
|
|
342
|
+
)
|
|
343
|
+
}
|
|
344
|
+
return provider.transcribe(audio, options)
|
|
345
|
+
}
|
|
346
|
+
|
|
197
347
|
/**
|
|
198
348
|
* Resolve an `Agent` subclass from the container and return an
|
|
199
349
|
* `AgentRunner` ready to receive `input(...)` and `run()`. Apps
|
|
200
350
|
* `@inject()`-decorate their Agent subclass so constructor
|
|
201
351
|
* injection of dependencies (Repositories, services, etc.) flows
|
|
202
352
|
* through normally.
|
|
353
|
+
*
|
|
354
|
+
* When the agent subclass extends `Agent<T>` for some `T` and
|
|
355
|
+
* declares `outputSchema`, the returned runner is typed as
|
|
356
|
+
* `AgentRunner<T>` and the schema is pre-applied — `.run()`
|
|
357
|
+
* returns `AgentGenerateResult<T>` without a per-call
|
|
358
|
+
* `.output(schema)`. Apps can still chain `.output(otherSchema)`
|
|
359
|
+
* to override.
|
|
203
360
|
*/
|
|
204
|
-
agent<
|
|
361
|
+
agent<T = never>(
|
|
362
|
+
AgentClass: new (...args: never[]) => Agent<T>,
|
|
363
|
+
instance?: Agent<T>,
|
|
364
|
+
): AgentRunner<T> {
|
|
205
365
|
const agent = instance ?? this.resolveAgent(AgentClass)
|
|
206
|
-
|
|
366
|
+
const runner = new AgentRunner<T>(this, agent)
|
|
367
|
+
if (agent.outputSchema !== undefined) {
|
|
368
|
+
return runner.output(agent.outputSchema)
|
|
369
|
+
}
|
|
370
|
+
return runner
|
|
207
371
|
}
|
|
208
372
|
|
|
209
373
|
// ─── Internal ────────────────────────────────────────────────────────────
|
|
210
374
|
|
|
211
|
-
private resolveAgent<A extends Agent
|
|
375
|
+
private resolveAgent<A extends Agent<unknown>>(AgentClass: new (...args: never[]) => A): A {
|
|
212
376
|
if (this.agentResolver) return this.agentResolver(AgentClass)
|
|
213
377
|
// Fallback: assume the Agent class is constructible without args.
|
|
214
378
|
// Apps that need DI on the agent register a resolver via
|
package/src/brain_provider.ts
CHANGED
|
@@ -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
|
@@ -8,16 +8,21 @@
|
|
|
8
8
|
// tools, structured outputs, other providers.
|
|
9
9
|
|
|
10
10
|
export { Agent } from './agent.ts'
|
|
11
|
+
export type { AgentGenerateResult } from './agent_generate_result.ts'
|
|
11
12
|
export type { AgentResult } from './agent_result.ts'
|
|
12
|
-
export { AgentRunner } from './agent_runner.ts'
|
|
13
|
+
export { AgentRunner, type AgentRunResult } from './agent_runner.ts'
|
|
14
|
+
export type { AgentStreamEvent } from './agent_stream_event.ts'
|
|
13
15
|
export {
|
|
14
16
|
type AnthropicProviderConfig,
|
|
15
17
|
type BrainCacheConfig,
|
|
16
18
|
type BrainConfigShape,
|
|
19
|
+
type DeepSeekProviderConfig,
|
|
17
20
|
DEFAULT_MODEL,
|
|
18
21
|
DEFAULT_TIERS,
|
|
19
22
|
type GeminiProviderConfig,
|
|
23
|
+
type OllamaProviderConfig,
|
|
20
24
|
type OpenAIProviderConfig,
|
|
25
|
+
type OpenAIResponsesProviderConfig,
|
|
21
26
|
type ProviderConfig,
|
|
22
27
|
} from './brain_config.ts'
|
|
23
28
|
export { BrainError } from './brain_error.ts'
|
|
@@ -31,8 +36,12 @@ export { defineTool, type DefineToolSpec } from './define_tool.ts'
|
|
|
31
36
|
export type { MCPServer, MCPServerToolConfig } from './mcp_server.ts'
|
|
32
37
|
export type { OutputSchema } from './output_schema.ts'
|
|
33
38
|
export { AnthropicProvider } from './providers/anthropic_provider.ts'
|
|
39
|
+
export { DeepSeekProvider } from './providers/deepseek_provider.ts'
|
|
34
40
|
export { GeminiProvider } from './providers/gemini_provider.ts'
|
|
41
|
+
export { OllamaProvider } from './providers/ollama_provider.ts'
|
|
42
|
+
export { OpenAICompatProvider } from './providers/openai_compat_provider.ts'
|
|
35
43
|
export { OpenAIProvider } from './providers/openai_provider.ts'
|
|
44
|
+
export { OpenAIResponsesProvider } from './providers/openai_responses_provider.ts'
|
|
36
45
|
export type { Provider, RunWithToolsOptions } from './provider.ts'
|
|
37
46
|
export { Thread, type ThreadOptions, type ThreadState } from './thread.ts'
|
|
38
47
|
export type { Tool, ToolContext } from './tool.ts'
|
|
@@ -42,14 +51,23 @@ export type {
|
|
|
42
51
|
ChatResult,
|
|
43
52
|
ChatUsage,
|
|
44
53
|
ContentBlock,
|
|
54
|
+
AudioBlock,
|
|
55
|
+
AudioSource,
|
|
56
|
+
DocumentBlock,
|
|
57
|
+
EmbedOptions,
|
|
58
|
+
EmbedResult,
|
|
45
59
|
GenerateResult,
|
|
60
|
+
ImageBlock,
|
|
46
61
|
MCPToolResultBlock,
|
|
47
62
|
MCPToolUseBlock,
|
|
48
63
|
Message,
|
|
49
64
|
ModelTier,
|
|
65
|
+
ServerTool,
|
|
50
66
|
StreamEvent,
|
|
51
67
|
SystemPrompt,
|
|
52
68
|
TextBlock,
|
|
53
69
|
ToolResultBlock,
|
|
54
70
|
ToolUseBlock,
|
|
71
|
+
TranscribeOptions,
|
|
72
|
+
TranscribeResult,
|
|
55
73
|
} 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`
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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,16 @@ export class MCPClient {
|
|
|
58
66
|
readonly server: MCPServer
|
|
59
67
|
private readonly _client: Client
|
|
60
68
|
private _connected = false
|
|
69
|
+
private _transport: StreamableHTTPClientTransport | undefined
|
|
70
|
+
private _authProvider: StoreBackedOAuthProvider | undefined
|
|
61
71
|
|
|
62
72
|
constructor(server: MCPServer, options: MCPClientOptions = {}) {
|
|
73
|
+
if (server.authorizationToken !== undefined && server.oauth !== undefined) {
|
|
74
|
+
throw new BrainError(
|
|
75
|
+
`MCPClient(${server.name}): \`authorizationToken\` and \`oauth\` are mutually exclusive — set one.`,
|
|
76
|
+
{ context: { server: server.name } },
|
|
77
|
+
)
|
|
78
|
+
}
|
|
63
79
|
this.server = server
|
|
64
80
|
this._client =
|
|
65
81
|
options.client ??
|
|
@@ -72,10 +88,17 @@ export class MCPClient {
|
|
|
72
88
|
async connect(): Promise<void> {
|
|
73
89
|
if (this._connected) return
|
|
74
90
|
const transport = this._buildTransport()
|
|
91
|
+
this._transport = transport
|
|
75
92
|
try {
|
|
76
93
|
await this._client.connect(transport)
|
|
77
94
|
this._connected = true
|
|
78
95
|
} catch (cause) {
|
|
96
|
+
if (cause instanceof UnauthorizedError && this._authProvider?.capturedAuthorizationUrl) {
|
|
97
|
+
throw new MCPAuthRequiredError(
|
|
98
|
+
this.server.name,
|
|
99
|
+
this._authProvider.capturedAuthorizationUrl.toString(),
|
|
100
|
+
)
|
|
101
|
+
}
|
|
79
102
|
throw new BrainError(
|
|
80
103
|
`MCPClient(${this.server.name}): failed to connect to ${this.server.url}.`,
|
|
81
104
|
{ context: { server: this.server.name, url: this.server.url }, cause },
|
|
@@ -83,11 +106,44 @@ export class MCPClient {
|
|
|
83
106
|
}
|
|
84
107
|
}
|
|
85
108
|
|
|
86
|
-
|
|
109
|
+
/**
|
|
110
|
+
* Finish the OAuth authorization-code flow after the user
|
|
111
|
+
* authorized and was redirected back to the app's callback URL.
|
|
112
|
+
* Exchanges `code` for tokens, persists them via the configured
|
|
113
|
+
* `MCPOAuthStore`, then connects.
|
|
114
|
+
*
|
|
115
|
+
* Throws `BrainError` when called on a server without OAuth
|
|
116
|
+
* configured.
|
|
117
|
+
*/
|
|
118
|
+
async completeAuthorization(code: string): Promise<void> {
|
|
119
|
+
if (this.server.oauth === undefined) {
|
|
120
|
+
throw new BrainError(
|
|
121
|
+
`MCPClient(${this.server.name}): completeAuthorization() called on a server without \`oauth\` configured.`,
|
|
122
|
+
{ context: { server: this.server.name } },
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
if (this._transport === undefined) {
|
|
126
|
+
// A previous `connect()` attempt builds the transport + captures
|
|
127
|
+
// the auth URL on the provider. Apps that lost the transport
|
|
128
|
+
// reference (typical for stateless callback handlers) can
|
|
129
|
+
// construct a fresh transport here — the store carries every
|
|
130
|
+
// bit of state the exchange needs.
|
|
131
|
+
this._transport = this._buildTransport()
|
|
132
|
+
}
|
|
133
|
+
await this._transport.finishAuth(code)
|
|
134
|
+
// Now that tokens are saved, re-attempt the connection.
|
|
135
|
+
this._connected = false
|
|
136
|
+
await this.connect()
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async listTools(opts: { signal?: AbortSignal } = {}): Promise<MCPToolDescriptor[]> {
|
|
87
140
|
await this.connect()
|
|
88
141
|
let response: Awaited<ReturnType<Client['listTools']>>
|
|
89
142
|
try {
|
|
90
|
-
response = await this._client.listTools(
|
|
143
|
+
response = await this._client.listTools(
|
|
144
|
+
undefined,
|
|
145
|
+
opts.signal !== undefined ? { signal: opts.signal } : undefined,
|
|
146
|
+
)
|
|
91
147
|
} catch (cause) {
|
|
92
148
|
throw new BrainError(
|
|
93
149
|
`MCPClient(${this.server.name}): tools/list failed.`,
|
|
@@ -101,14 +157,22 @@ export class MCPClient {
|
|
|
101
157
|
}))
|
|
102
158
|
}
|
|
103
159
|
|
|
104
|
-
async callTool(
|
|
160
|
+
async callTool(
|
|
161
|
+
name: string,
|
|
162
|
+
input: unknown,
|
|
163
|
+
opts: { signal?: AbortSignal } = {},
|
|
164
|
+
): Promise<MCPCallToolResult> {
|
|
105
165
|
await this.connect()
|
|
106
166
|
let response: Awaited<ReturnType<Client['callTool']>>
|
|
107
167
|
try {
|
|
108
|
-
response = await this._client.callTool(
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
168
|
+
response = await this._client.callTool(
|
|
169
|
+
{
|
|
170
|
+
name,
|
|
171
|
+
arguments: (input ?? {}) as Record<string, unknown>,
|
|
172
|
+
},
|
|
173
|
+
undefined,
|
|
174
|
+
opts.signal !== undefined ? { signal: opts.signal } : undefined,
|
|
175
|
+
)
|
|
112
176
|
} catch (cause) {
|
|
113
177
|
throw new BrainError(
|
|
114
178
|
`MCPClient(${this.server.name}): tools/call ${name} failed.`,
|
|
@@ -135,9 +199,14 @@ export class MCPClient {
|
|
|
135
199
|
if (this.server.authorizationToken !== undefined) {
|
|
136
200
|
headers.Authorization = `Bearer ${this.server.authorizationToken}`
|
|
137
201
|
}
|
|
138
|
-
|
|
202
|
+
const transportOpts: ConstructorParameters<typeof StreamableHTTPClientTransport>[1] = {
|
|
139
203
|
requestInit: { headers },
|
|
140
|
-
}
|
|
204
|
+
}
|
|
205
|
+
if (this.server.oauth !== undefined) {
|
|
206
|
+
this._authProvider = new StoreBackedOAuthProvider(this.server.oauth)
|
|
207
|
+
transportOpts.authProvider = this._authProvider
|
|
208
|
+
}
|
|
209
|
+
return new StreamableHTTPClientTransport(new URL(this.server.url), transportOpts)
|
|
141
210
|
}
|
|
142
211
|
}
|
|
143
212
|
|
package/src/mcp/index.ts
CHANGED
|
@@ -9,6 +9,12 @@ 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'
|
|
12
18
|
export {
|
|
13
19
|
resolveMcpTools,
|
|
14
20
|
type ResolveMcpToolsOptions,
|