@strav/brain 1.0.0-alpha.9 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/package.json +23 -7
  2. package/src/agent.ts +43 -5
  3. package/src/agent_generate_result.ts +32 -0
  4. package/src/agent_result.ts +7 -0
  5. package/src/agent_runner.ts +218 -14
  6. package/src/agent_stream_event.ts +100 -0
  7. package/src/brain_config.ts +218 -1
  8. package/src/brain_driver.ts +247 -0
  9. package/src/brain_error.ts +86 -10
  10. package/src/brain_manager.ts +359 -11
  11. package/src/brain_provider.ts +79 -9
  12. package/src/drivers/anthropic/anthropic_brain_driver.ts +641 -0
  13. package/src/drivers/anthropic/anthropic_helpers.ts +65 -0
  14. package/src/drivers/anthropic/anthropic_message_builder.ts +258 -0
  15. package/src/drivers/anthropic/anthropic_response_mapper.ts +123 -0
  16. package/src/drivers/anthropic/anthropic_tool_loop.ts +246 -0
  17. package/src/drivers/anthropic/index.ts +1 -0
  18. package/src/drivers/deepseek/deepseek_brain_driver.ts +117 -0
  19. package/src/drivers/deepseek/index.ts +1 -0
  20. package/src/drivers/gemini/gemini_brain_driver.ts +1064 -0
  21. package/src/drivers/gemini/index.ts +1 -0
  22. package/src/drivers/minimax/index.ts +1 -0
  23. package/src/drivers/minimax/minimax_brain_driver.ts +84 -0
  24. package/src/drivers/ollama/index.ts +1 -0
  25. package/src/drivers/ollama/ollama_brain_driver.ts +86 -0
  26. package/src/drivers/openai/index.ts +1 -0
  27. package/src/drivers/openai/openai_brain_driver.ts +796 -0
  28. package/src/drivers/openai/openai_helpers.ts +58 -0
  29. package/src/drivers/openai/openai_message_builder.ts +187 -0
  30. package/src/drivers/openai/openai_response_mapper.ts +70 -0
  31. package/src/drivers/openai/openai_tool_dispatch.ts +127 -0
  32. package/src/drivers/openai/openai_tool_loop.ts +191 -0
  33. package/src/drivers/openai_compat/index.ts +1 -0
  34. package/src/drivers/openai_compat/openai_compat_brain_driver.ts +616 -0
  35. package/src/drivers/openai_responses/index.ts +1 -0
  36. package/src/drivers/openai_responses/openai_responses_brain_driver.ts +1015 -0
  37. package/src/drivers/openrouter/index.ts +1 -0
  38. package/src/drivers/openrouter/openrouter_brain_driver.ts +137 -0
  39. package/src/drivers/qwen/index.ts +1 -0
  40. package/src/drivers/qwen/qwen_brain_driver.ts +103 -0
  41. package/src/index.ts +75 -11
  42. package/src/mcp/client.ts +243 -0
  43. package/src/mcp/index.ts +23 -0
  44. package/src/mcp/oauth.ts +227 -0
  45. package/src/mcp/pool.ts +106 -0
  46. package/src/mcp/resolve_mcp_tools.ts +108 -0
  47. package/src/mcp_server.ts +63 -0
  48. package/src/output_schema.ts +72 -0
  49. package/src/persistence/brain_message.ts +34 -0
  50. package/src/persistence/brain_message_repository.ts +98 -0
  51. package/src/persistence/brain_store.ts +166 -0
  52. package/src/persistence/brain_suspended_run.ts +30 -0
  53. package/src/persistence/brain_suspended_run_repository.ts +59 -0
  54. package/src/persistence/brain_thread.ts +30 -0
  55. package/src/persistence/brain_thread_repository.ts +56 -0
  56. package/src/persistence/database_brain_store.ts +190 -0
  57. package/src/persistence/index.ts +48 -0
  58. package/src/persistence/schemas/brain_message_schema.ts +61 -0
  59. package/src/persistence/schemas/brain_suspended_run_schema.ts +58 -0
  60. package/src/persistence/schemas/brain_thread_schema.ts +50 -0
  61. package/src/persistence/schemas/index.ts +3 -0
  62. package/src/suspended_run.ts +153 -0
  63. package/src/thread.ts +40 -1
  64. package/src/tool.ts +7 -0
  65. package/src/tool_runner.ts +81 -0
  66. package/src/translate/index.ts +19 -0
  67. package/src/translate/translate_cache.ts +78 -0
  68. package/src/translate/translate_provider.ts +46 -0
  69. package/src/translate/translator.ts +271 -0
  70. package/src/types.ts +398 -1
  71. package/src/zod/index.ts +121 -0
  72. package/src/provider.ts +0 -74
  73. package/src/providers/anthropic_provider.ts +0 -397
@@ -0,0 +1 @@
1
+ export { OpenRouterBrainDriver } from './openrouter_brain_driver.ts'
@@ -0,0 +1,137 @@
1
+ /**
2
+ * `OpenRouterBrainDriver` — `OpenAICompatBrainDriver` pointed at
3
+ * OpenRouter's OpenAI-compatible endpoint at
4
+ * `https://openrouter.ai/api/v1`.
5
+ *
6
+ * OpenRouter multiplexes 300+ hosted models (Anthropic, OpenAI,
7
+ * Google, Meta, Mistral, DeepSeek, Qwen, ...) behind one
8
+ * Chat-Completions-shaped API. The driver does NOT try to predict
9
+ * which model supports what — capabilities vary per slug. The
10
+ * supported approach is:
11
+ *
12
+ * - Pin an explicit model slug per call (e.g.
13
+ * `anthropic/claude-sonnet-4`, `meta-llama/llama-3.3-70b`).
14
+ * - If the chosen model doesn't support a primitive (tools,
15
+ * JSON mode, schema-forcing), the upstream OpenRouter error
16
+ * bubbles up as a `BrainError` from the inherited helpers.
17
+ * - Consult `https://openrouter.ai/api/v1/models` and filter on
18
+ * the `supported_parameters` field to pick a model that
19
+ * handles the feature you need.
20
+ *
21
+ * Two OpenRouter-specific niceties:
22
+ *
23
+ * - Optional `appUrl` (→ `HTTP-Referer`) and `appTitle`
24
+ * (→ `X-Title`) forwarded as default headers on every request.
25
+ * OpenRouter uses these to attribute traffic on their public
26
+ * leaderboard / rankings.
27
+ *
28
+ * - No `defaultModel` default — OpenRouter has no canonical pick.
29
+ * Apps must set one, or pass `options.model` per call. The
30
+ * inherited OpenAI default `gpt-5` would silently route through
31
+ * OpenAI's slug namespace and surprise users.
32
+ *
33
+ * Inherits all OpenAI-compat overrides: `buildParams` strips
34
+ * `reasoning_effort` (re-add it in a subclass / per-call `extraBody`
35
+ * for models that take it), `generate` uses `json_object`-mode +
36
+ * schema-in-system-prompt, `runWithToolsAndSchema` /
37
+ * `streamWithToolsAndSchema` use the tool-forcing pattern.
38
+ *
39
+ * `embed` / `transcribe` are not exposed via OpenRouter — replaced
40
+ * with explicit `BrainError`s.
41
+ */
42
+
43
+ import OpenAI from 'openai'
44
+ import type { OpenRouterProviderConfig } from '../../brain_config.ts'
45
+ import { BrainError } from '../../brain_error.ts'
46
+ import type { ResolveMcpToolsOptions } from '../../mcp/resolve_mcp_tools.ts'
47
+ import type {
48
+ AudioSource,
49
+ EmbedOptions,
50
+ EmbedResult,
51
+ TranscribeOptions,
52
+ TranscribeResult,
53
+ } from '../../types.ts'
54
+ import { OpenAICompatBrainDriver } from '../openai_compat/openai_compat_brain_driver.ts'
55
+
56
+ const DEFAULT_OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1'
57
+ /**
58
+ * Fallback when neither `config.defaultModel` nor a per-call
59
+ * `options.model` is supplied. Picked as a broad-purpose,
60
+ * tool-capable, well-priced default; apps that want something else
61
+ * just pass `defaultModel`.
62
+ */
63
+ const DEFAULT_OPENROUTER_MODEL = 'openai/gpt-4o-mini'
64
+
65
+ export interface OpenRouterProviderOptions {
66
+ client?: OpenAI
67
+ /**
68
+ * Internal seam — tests inject a stub MCP client factory so MCP
69
+ * tool resolution doesn't dial the network. Real apps leave it
70
+ * unset; the provider uses the default `MCPClient`.
71
+ */
72
+ mcpClientFactory?: ResolveMcpToolsOptions['clientFactory']
73
+ }
74
+
75
+ export class OpenRouterBrainDriver extends OpenAICompatBrainDriver {
76
+ constructor(
77
+ name: string,
78
+ config: OpenRouterProviderConfig,
79
+ options: OpenRouterProviderOptions = {},
80
+ ) {
81
+ const baseURL = config.baseUrl ?? DEFAULT_OPENROUTER_BASE_URL
82
+ const defaultHeaders = buildOpenRouterHeaders(config)
83
+ // Construct the OpenAI client locally so we can carry OpenRouter's
84
+ // attribution headers (HTTP-Referer / X-Title). The base
85
+ // OpenAIBrainDriver only exposes apiKey / baseUrl / organization,
86
+ // so we hand a fully-built client through `options.client`.
87
+ const client =
88
+ options.client ??
89
+ new OpenAI({
90
+ apiKey: config.apiKey,
91
+ baseURL,
92
+ ...(defaultHeaders ? { defaultHeaders } : {}),
93
+ })
94
+ super(
95
+ name,
96
+ {
97
+ driver: 'openai',
98
+ apiKey: config.apiKey,
99
+ baseUrl: baseURL,
100
+ defaultModel: config.defaultModel ?? DEFAULT_OPENROUTER_MODEL,
101
+ ...(config.defaultMaxTokens !== undefined
102
+ ? { defaultMaxTokens: config.defaultMaxTokens }
103
+ : {}),
104
+ },
105
+ { ...options, client },
106
+ )
107
+ }
108
+
109
+ override async embed(
110
+ _texts: readonly string[],
111
+ _options?: EmbedOptions,
112
+ ): Promise<EmbedResult<OpenAI.CreateEmbeddingResponse>> {
113
+ throw new BrainError(
114
+ `OpenRouterBrainDriver.embed: OpenRouter's API does not expose embeddings. Route embed calls to a provider with native support — OpenAI / Gemini / Ollama.`,
115
+ { context: { provider: this.name } },
116
+ )
117
+ }
118
+
119
+ override async transcribe(
120
+ _audio: AudioSource,
121
+ _options?: TranscribeOptions,
122
+ ): Promise<TranscribeResult<OpenAI.Audio.TranscriptionCreateResponse>> {
123
+ throw new BrainError(
124
+ "OpenRouterBrainDriver.transcribe: OpenRouter's API does not expose audio transcription. Route transcribe calls to a provider with native support — OpenAI / Ollama / Gemini.",
125
+ { context: { provider: this.name } },
126
+ )
127
+ }
128
+ }
129
+
130
+ function buildOpenRouterHeaders(
131
+ config: OpenRouterProviderConfig,
132
+ ): Record<string, string> | undefined {
133
+ const headers: Record<string, string> = {}
134
+ if (config.appUrl !== undefined) headers['HTTP-Referer'] = config.appUrl
135
+ if (config.appTitle !== undefined) headers['X-Title'] = config.appTitle
136
+ return Object.keys(headers).length > 0 ? headers : undefined
137
+ }
@@ -0,0 +1 @@
1
+ export { QwenBrainDriver } from './qwen_brain_driver.ts'
@@ -0,0 +1,103 @@
1
+ /**
2
+ * `QwenBrainDriver` — `OpenAICompatBrainDriver` pointed at Alibaba
3
+ * DashScope's OpenAI-compatible `/compatible-mode/v1` endpoint.
4
+ *
5
+ * Why ship Qwen: deep coverage of Chinese + SEA languages, with the
6
+ * `qwen-plus` / `qwen-max` line punching at the frontier on those
7
+ * locales. Fits Strav's SEA-first positioning.
8
+ *
9
+ * Inherits the OpenAI-compat overrides (strip `reasoning_effort`,
10
+ * `json_object`-mode generate with schema-in-system-prompt,
11
+ * tool-forcing pattern for combined tools + schema) from the base
12
+ * class. Only adds:
13
+ *
14
+ * - Constructor with DashScope defaults — base URL
15
+ * `https://dashscope-intl.aliyuncs.com/compatible-mode/v1` (the
16
+ * Singapore endpoint), default model `qwen-plus`.
17
+ *
18
+ * - `mapUsage` override — Qwen reports prompt-cache hits on
19
+ * OpenAI's standard `prompt_tokens_details.cached_tokens`
20
+ * field; the inherited mapping is already correct. Kept the
21
+ * hook here for vendor-specific extension fields if they
22
+ * surface later.
23
+ *
24
+ * `embed` / `transcribe` are not implemented for V1 — DashScope
25
+ * exposes both via separate endpoints, but the framework's seam
26
+ * is per-driver and we'd rather route those calls to a provider
27
+ * with first-class support. Override the inherited stubs to throw
28
+ * with a clear message instead of letting the SDK 404.
29
+ */
30
+
31
+ import type OpenAI from 'openai'
32
+ import type { QwenProviderConfig } from '../../brain_config.ts'
33
+ import { BrainError } from '../../brain_error.ts'
34
+ import type { ResolveMcpToolsOptions } from '../../mcp/resolve_mcp_tools.ts'
35
+ import type {
36
+ AudioSource,
37
+ EmbedOptions,
38
+ EmbedResult,
39
+ TranscribeOptions,
40
+ TranscribeResult,
41
+ } from '../../types.ts'
42
+ import { OpenAICompatBrainDriver } from '../openai_compat/openai_compat_brain_driver.ts'
43
+
44
+ const DEFAULT_QWEN_MODEL = 'qwen-plus'
45
+ const DEFAULT_QWEN_BASE_URL = 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1'
46
+
47
+ export interface QwenProviderOptions {
48
+ client?: OpenAI
49
+ /**
50
+ * Internal seam — tests inject a stub MCP client factory so MCP
51
+ * tool resolution doesn't dial the network. Real apps leave it
52
+ * unset; the provider uses the default `MCPClient`.
53
+ */
54
+ mcpClientFactory?: ResolveMcpToolsOptions['clientFactory']
55
+ }
56
+
57
+ export class QwenBrainDriver extends OpenAICompatBrainDriver {
58
+ constructor(name: string, config: QwenProviderConfig, options: QwenProviderOptions = {}) {
59
+ super(
60
+ name,
61
+ {
62
+ driver: 'openai',
63
+ apiKey: config.apiKey,
64
+ baseUrl: config.baseUrl ?? DEFAULT_QWEN_BASE_URL,
65
+ defaultModel: config.defaultModel ?? DEFAULT_QWEN_MODEL,
66
+ ...(config.defaultMaxTokens !== undefined
67
+ ? { defaultMaxTokens: config.defaultMaxTokens }
68
+ : {}),
69
+ },
70
+ options,
71
+ )
72
+ }
73
+
74
+ /**
75
+ * DashScope's compatibility endpoint does not expose embeddings
76
+ * via `/compatible-mode/v1/embeddings`. Override the inherited
77
+ * `embed` to throw clearly rather than 404 at the wire.
78
+ */
79
+ override async embed(
80
+ _texts: readonly string[],
81
+ _options?: EmbedOptions,
82
+ ): Promise<EmbedResult<OpenAI.CreateEmbeddingResponse>> {
83
+ throw new BrainError(
84
+ `QwenBrainDriver.embed: Qwen's OpenAI-compatibility endpoint does not expose embeddings. Route embed calls to a provider with native support — OpenAI / Gemini / Ollama.`,
85
+ { context: { provider: this.name } },
86
+ )
87
+ }
88
+
89
+ /**
90
+ * DashScope's compatibility endpoint does not expose audio
91
+ * transcription. Override the inherited `transcribe` to throw
92
+ * clearly.
93
+ */
94
+ override async transcribe(
95
+ _audio: AudioSource,
96
+ _options?: TranscribeOptions,
97
+ ): Promise<TranscribeResult<OpenAI.Audio.TranscriptionCreateResponse>> {
98
+ throw new BrainError(
99
+ "QwenBrainDriver.transcribe: Qwen's OpenAI-compatibility endpoint does not expose audio transcription. Route transcribe calls to a provider with native support — OpenAI / Ollama / Gemini.",
100
+ { context: { provider: this.name } },
101
+ )
102
+ }
103
+ }
package/src/index.ts CHANGED
@@ -1,46 +1,110 @@
1
1
  // Public API of @strav/brain.
2
2
  //
3
- // V1: Provider interface + AnthropicProvider, BrainManager, Thread,
4
- // BrainProvider service-wiring, prompt caching.
5
- // V2 (this slice): tools + agents defineTool, Agent base + AgentRunner,
6
- // BrainManager.runTools / .agent(Class), Provider.runWithTools.
7
- // Still deferred: MCP, embeddings, streaming agent loops, server-side
8
- // tools, structured outputs, other providers.
3
+ // Shipped:
4
+ // - `BrainDriver` contract + concrete drivers — Anthropic, OpenAI
5
+ // (Chat + Responses), Gemini, DeepSeek, Ollama, Qwen, MiniMax,
6
+ // OpenRouter, openai-compat.
7
+ // - `BrainManager` + `Thread` (persisted history, compaction) + the
8
+ // `BrainProvider` service wiring + prompt-cache plumbing.
9
+ // - Tools — `defineTool`, `Agent` base + `AgentRunner`,
10
+ // `runWithTools` + `streamWithTools` (agentic + streaming loops).
11
+ // - Structured outputs — `generate`, `runWithToolsAndSchema`,
12
+ // `streamWithToolsAndSchema` (JSON-Schema constrained replies).
13
+ // - MCP — server-side (Anthropic) + local client (`@strav/brain/mcp`).
14
+ // - Embeddings (`embed`), audio transcription (`transcribe`),
15
+ // human-in-the-loop suspend/resume.
9
16
 
10
17
  export { Agent } from './agent.ts'
18
+ export type { AgentGenerateResult } from './agent_generate_result.ts'
11
19
  export type { AgentResult } from './agent_result.ts'
12
- export { AgentRunner } from './agent_runner.ts'
20
+ export {
21
+ type AgentRunMaybeSuspended,
22
+ AgentRunner,
23
+ type AgentRunResult,
24
+ } from './agent_runner.ts'
25
+ export type { AgentStreamEvent } from './agent_stream_event.ts'
13
26
  export {
14
27
  type AnthropicProviderConfig,
15
28
  type BrainCacheConfig,
16
29
  type BrainConfigShape,
17
30
  DEFAULT_MODEL,
18
31
  DEFAULT_TIERS,
32
+ type DeepSeekProviderConfig,
33
+ type GeminiProviderConfig,
34
+ type MiniMaxProviderConfig,
35
+ type OllamaProviderConfig,
36
+ type OpenAIProviderConfig,
37
+ type OpenAIResponsesProviderConfig,
38
+ type OpenRouterProviderConfig,
19
39
  type ProviderConfig,
40
+ type QwenProviderConfig,
20
41
  } from './brain_config.ts'
21
- export { BrainError } from './brain_error.ts'
42
+ export type {
43
+ BrainDriver,
44
+ RunWithToolsOptions,
45
+ RunWithToolsOptionsWithSuspend,
46
+ } from './brain_driver.ts'
47
+ export {
48
+ BrainConfigError,
49
+ BrainError,
50
+ BrainProviderError,
51
+ BrainUsageError,
52
+ UnknownProviderError,
53
+ } from './brain_error.ts'
22
54
  export {
23
55
  type AgentResolver,
24
56
  BrainManager,
25
57
  type BrainManagerOptions,
26
58
  } from './brain_manager.ts'
27
59
  export { BrainProvider } from './brain_provider.ts'
28
- export { defineTool, type DefineToolSpec } from './define_tool.ts'
29
- export { AnthropicProvider } from './providers/anthropic_provider.ts'
30
- export type { Provider, RunWithToolsOptions } from './provider.ts'
60
+ export { type DefineToolSpec, defineTool } from './define_tool.ts'
61
+ export { AnthropicBrainDriver } from './drivers/anthropic/anthropic_brain_driver.ts'
62
+ export { DeepSeekBrainDriver } from './drivers/deepseek/deepseek_brain_driver.ts'
63
+ export { GeminiBrainDriver } from './drivers/gemini/gemini_brain_driver.ts'
64
+ export { MiniMaxBrainDriver } from './drivers/minimax/minimax_brain_driver.ts'
65
+ export { OllamaBrainDriver } from './drivers/ollama/ollama_brain_driver.ts'
66
+ export { OpenAIBrainDriver } from './drivers/openai/openai_brain_driver.ts'
67
+ export { OpenAICompatBrainDriver } from './drivers/openai_compat/openai_compat_brain_driver.ts'
68
+ export { OpenAIResponsesBrainDriver } from './drivers/openai_responses/openai_responses_brain_driver.ts'
69
+ export { OpenRouterBrainDriver } from './drivers/openrouter/openrouter_brain_driver.ts'
70
+ export { QwenBrainDriver } from './drivers/qwen/qwen_brain_driver.ts'
71
+ export { type MCPClientFactory, MCPClientPool } from './mcp/pool.ts'
72
+ export type { MCPServer, MCPServerToolConfig } from './mcp_server.ts'
73
+ export type { OutputSchema } from './output_schema.ts'
74
+ export {
75
+ appendResumeResults,
76
+ isSuspended,
77
+ type SuspendedRun,
78
+ type SuspendedState,
79
+ type ToolResultInput,
80
+ } from './suspended_run.ts'
31
81
  export { Thread, type ThreadOptions, type ThreadState } from './thread.ts'
32
82
  export type { Tool, ToolContext } from './tool.ts'
33
83
  export { ToolExecutionError } from './tool_execution_error.ts'
34
84
  export type {
85
+ AudioBlock,
86
+ AudioSource,
35
87
  ChatOptions,
36
88
  ChatResult,
37
89
  ChatUsage,
90
+ CompactConfig,
91
+ CompactionBlock,
38
92
  ContentBlock,
93
+ DocumentBlock,
94
+ EmbedOptions,
95
+ EmbedResult,
96
+ GenerateResult,
97
+ ImageBlock,
98
+ MCPToolResultBlock,
99
+ MCPToolUseBlock,
39
100
  Message,
40
101
  ModelTier,
102
+ ServerTool,
41
103
  StreamEvent,
42
104
  SystemPrompt,
43
105
  TextBlock,
44
106
  ToolResultBlock,
45
107
  ToolUseBlock,
108
+ TranscribeOptions,
109
+ TranscribeResult,
46
110
  } from './types.ts'
@@ -0,0 +1,243 @@
1
+ /**
2
+ * `MCPClient` — local MCP client for providers that lack server-side
3
+ * MCP support (OpenAI, Gemini, DeepSeek, …).
4
+ *
5
+ * Wraps the official `@modelcontextprotocol/sdk` client. Connects to a
6
+ * single MCP server over Streamable HTTP, lists its tools, and invokes
7
+ * them. The agentic loop sees these as ordinary `Tool`s — translation
8
+ * happens in `resolveMcpTools`.
9
+ *
10
+ * Lifecycle:
11
+ *
12
+ * const client = new MCPClient(serverConfig)
13
+ * await client.connect()
14
+ * const tools = await client.listTools()
15
+ * const result = await client.callTool('name', {...})
16
+ * await client.close()
17
+ *
18
+ * Authentication:
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.
29
+ *
30
+ * Transport:
31
+ * V1 only does Streamable HTTP — the current MCP transport. Legacy
32
+ * SSE-only endpoints aren't supported; if a server URL ends with
33
+ * `/sse` and only speaks the legacy protocol, the connection will
34
+ * fail and apps should run against a Streamable-HTTP endpoint
35
+ * instead.
36
+ */
37
+
38
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js'
39
+ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
40
+ import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js'
41
+ import { BrainError } from '../brain_error.ts'
42
+ import type { MCPServer } from '../mcp_server.ts'
43
+ import { MCPAuthRequiredError, StoreBackedOAuthProvider } from './oauth.ts'
44
+
45
+ /** Result of a single MCP tool invocation, as returned by `tools/call`. */
46
+ export interface MCPCallToolResult {
47
+ /** Stringified content — text blocks concatenated; image / resource blocks JSON-serialized. */
48
+ content: string
49
+ /** `true` when the MCP server reports the tool execution failed. */
50
+ isError: boolean
51
+ }
52
+
53
+ /** Tool descriptor surfaced by `tools/list`. */
54
+ export interface MCPToolDescriptor {
55
+ name: string
56
+ description: string
57
+ inputSchema: Record<string, unknown>
58
+ }
59
+
60
+ export interface MCPClientOptions {
61
+ /** Override the transport used to dial the server. Tests inject a mock here. */
62
+ client?: Client
63
+ }
64
+
65
+ export class MCPClient {
66
+ readonly server: MCPServer
67
+ private readonly _client: Client
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
80
+
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
+ }
88
+ this.server = server
89
+ this._client =
90
+ options.client ??
91
+ new Client(
92
+ { name: `@strav/brain:${server.name}`, version: '1.0.0' },
93
+ { capabilities: {} },
94
+ )
95
+ }
96
+
97
+ async connect(): Promise<void> {
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> {
107
+ const transport = this._buildTransport()
108
+ this._transport = transport
109
+ try {
110
+ await this._client.connect(transport)
111
+ this._connected = true
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
+ }
119
+ throw new BrainError(
120
+ `MCPClient(${this.server.name}): failed to connect to ${this.server.url}.`,
121
+ { context: { server: this.server.name, url: this.server.url }, cause },
122
+ )
123
+ }
124
+ }
125
+
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[]> {
157
+ await this.connect()
158
+ let response: Awaited<ReturnType<Client['listTools']>>
159
+ try {
160
+ response = await this._client.listTools(
161
+ undefined,
162
+ opts.signal !== undefined ? { signal: opts.signal } : undefined,
163
+ )
164
+ } catch (cause) {
165
+ throw new BrainError(
166
+ `MCPClient(${this.server.name}): tools/list failed.`,
167
+ { context: { server: this.server.name }, cause },
168
+ )
169
+ }
170
+ return response.tools.map((t) => ({
171
+ name: t.name,
172
+ description: t.description ?? '',
173
+ inputSchema: (t.inputSchema ?? { type: 'object' }) as Record<string, unknown>,
174
+ }))
175
+ }
176
+
177
+ async callTool(
178
+ name: string,
179
+ input: unknown,
180
+ opts: { signal?: AbortSignal } = {},
181
+ ): Promise<MCPCallToolResult> {
182
+ await this.connect()
183
+ let response: Awaited<ReturnType<Client['callTool']>>
184
+ try {
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
+ )
193
+ } catch (cause) {
194
+ throw new BrainError(
195
+ `MCPClient(${this.server.name}): tools/call ${name} failed.`,
196
+ { context: { server: this.server.name, tool: name }, cause },
197
+ )
198
+ }
199
+ return {
200
+ content: flattenContent(response.content),
201
+ isError: Boolean(response.isError),
202
+ }
203
+ }
204
+
205
+ async close(): Promise<void> {
206
+ if (!this._connected) return
207
+ try {
208
+ await this._client.close()
209
+ } finally {
210
+ this._connected = false
211
+ }
212
+ }
213
+
214
+ private _buildTransport(): StreamableHTTPClientTransport {
215
+ const headers: Record<string, string> = {}
216
+ if (this.server.authorizationToken !== undefined) {
217
+ headers.Authorization = `Bearer ${this.server.authorizationToken}`
218
+ }
219
+ const transportOpts: ConstructorParameters<typeof StreamableHTTPClientTransport>[1] = {
220
+ requestInit: { headers },
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)
227
+ }
228
+ }
229
+
230
+ function flattenContent(
231
+ content: Awaited<ReturnType<Client['callTool']>>['content'],
232
+ ): string {
233
+ if (!Array.isArray(content)) return ''
234
+ const parts: string[] = []
235
+ for (const block of content) {
236
+ if (block.type === 'text') {
237
+ parts.push(block.text)
238
+ } else {
239
+ parts.push(JSON.stringify(block))
240
+ }
241
+ }
242
+ return parts.join('')
243
+ }
@@ -0,0 +1,23 @@
1
+ // Public API of `@strav/brain/mcp` — local MCP client for providers
2
+ // without server-side MCP support (OpenAI, Gemini, DeepSeek). The
3
+ // Anthropic provider continues to use server-side MCP via the
4
+ // top-level `MCPServer` config; nothing here is needed for that path.
5
+
6
+ export {
7
+ MCPClient,
8
+ type MCPCallToolResult,
9
+ type MCPClientOptions,
10
+ type MCPToolDescriptor,
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'
19
+ export {
20
+ resolveMcpTools,
21
+ type ResolveMcpToolsOptions,
22
+ type ResolvedMcpTools,
23
+ } from './resolve_mcp_tools.ts'