@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.
@@ -0,0 +1,227 @@
1
+ /**
2
+ * OAuth support for the local MCP client.
3
+ *
4
+ * Most real-world MCP servers (Linear, Notion, GitHub, Asana,
5
+ * Atlassian) are OAuth-protected. The local client at
6
+ * `@strav/brain/mcp` already supports static bearer tokens via
7
+ * `MCPServer.authorizationToken` — fine for self-hosted servers,
8
+ * useless against any commercial server. This module closes the
9
+ * gap.
10
+ *
11
+ * The flow apps see:
12
+ *
13
+ * ```ts
14
+ * const store = new MemoryOAuthStore()
15
+ * const linear: MCPServer = {
16
+ * name: 'linear',
17
+ * url: 'https://mcp.linear.app',
18
+ * oauth: {
19
+ * redirectUri: 'https://myapp.com/mcp/linear/callback',
20
+ * scope: 'read',
21
+ * store,
22
+ * },
23
+ * }
24
+ *
25
+ * try {
26
+ * const client = new MCPClient(linear)
27
+ * await client.connect()
28
+ * } catch (err) {
29
+ * if (err instanceof MCPAuthRequiredError) {
30
+ * // Redirect the user to err.authorizationUrl and remember
31
+ * // who they were (so the callback handler can rebuild the
32
+ * // store with the right per-user state).
33
+ * }
34
+ * }
35
+ *
36
+ * // Later, in the callback handler:
37
+ * const client = new MCPClient(linear)
38
+ * await client.completeAuthorization(req.query.code)
39
+ * // The store now has tokens; subsequent connect()s succeed.
40
+ * ```
41
+ *
42
+ * The framework is server-side and headless — it can't redirect
43
+ * the user inline. So instead of blocking on `connect()`, we
44
+ * surface `MCPAuthRequiredError` carrying the authorization URL.
45
+ * Apps redirect the user themselves, then call
46
+ * `MCPClient.completeAuthorization(code)` from their callback
47
+ * route.
48
+ *
49
+ * Multi-tenancy: build a fresh `MCPOAuthStore` per `(user, server)`
50
+ * with the user id baked into the storage keys. The store
51
+ * interface is intentionally per-server (no `userId` arg) so apps
52
+ * pick the boundary that matches their data model.
53
+ */
54
+
55
+ import type {
56
+ OAuthClientInformation,
57
+ OAuthClientInformationFull,
58
+ OAuthClientMetadata,
59
+ OAuthTokens,
60
+ } from '@modelcontextprotocol/sdk/shared/auth.js'
61
+ import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js'
62
+ import { BrainError } from '../brain_error.ts'
63
+
64
+ /**
65
+ * Persistence contract for one MCP server's OAuth state.
66
+ *
67
+ * Methods may be sync or async — implementations are free to call
68
+ * a DB, a Redis cache, a file, or hold the state in memory. The
69
+ * framework awaits whichever shape returns.
70
+ *
71
+ * Per-(server) only by design — apps that need per-(user, server)
72
+ * scoping construct a fresh store per request with the user id
73
+ * baked into the underlying storage keys.
74
+ */
75
+ export interface MCPOAuthStore {
76
+ /** Load the dynamic-client-registration record, or undefined if not registered. */
77
+ clientInformation():
78
+ | OAuthClientInformation
79
+ | undefined
80
+ | Promise<OAuthClientInformation | undefined>
81
+ /** Persist the dynamic-client-registration record after registration succeeds. */
82
+ saveClientInformation(info: OAuthClientInformationFull): void | Promise<void>
83
+ /** Load the active token set, or undefined if the user hasn't authorized. */
84
+ tokens(): OAuthTokens | undefined | Promise<OAuthTokens | undefined>
85
+ /** Persist tokens after authorization completes or a refresh succeeds. */
86
+ saveTokens(tokens: OAuthTokens): void | Promise<void>
87
+ /** Load the PKCE code verifier saved during the authorize step. */
88
+ codeVerifier(): string | Promise<string>
89
+ /** Persist the PKCE code verifier before redirecting to authorize. */
90
+ saveCodeVerifier(verifier: string): void | Promise<void>
91
+ }
92
+
93
+ /** Per-server OAuth configuration on `MCPServer.oauth`. */
94
+ export interface MCPOAuthConfig {
95
+ /** Where the user comes back after authorizing. Must match a registered redirect URI on the OAuth server. */
96
+ redirectUri: string
97
+ /** Optional OAuth scopes to request. Some servers require specific scopes. */
98
+ scope?: string
99
+ /** Per-server token + client-info storage. */
100
+ store: MCPOAuthStore
101
+ /** Optional client metadata for dynamic client registration. Defaults to a minimal sane shape. */
102
+ clientMetadata?: Partial<OAuthClientMetadata>
103
+ }
104
+
105
+ /**
106
+ * `MemoryOAuthStore` — in-memory `MCPOAuthStore` implementation.
107
+ *
108
+ * Fine for tests and single-process dev. Production apps with
109
+ * multiple processes or restarts persist to a DB / Redis / KV.
110
+ */
111
+ export class MemoryOAuthStore implements MCPOAuthStore {
112
+ private _clientInfo: OAuthClientInformationFull | undefined
113
+ private _tokens: OAuthTokens | undefined
114
+ private _verifier: string | undefined
115
+
116
+ clientInformation(): OAuthClientInformation | undefined {
117
+ return this._clientInfo
118
+ }
119
+ saveClientInformation(info: OAuthClientInformationFull): void {
120
+ this._clientInfo = info
121
+ }
122
+ tokens(): OAuthTokens | undefined {
123
+ return this._tokens
124
+ }
125
+ saveTokens(tokens: OAuthTokens): void {
126
+ this._tokens = tokens
127
+ }
128
+ codeVerifier(): string {
129
+ if (this._verifier === undefined) {
130
+ throw new BrainError(
131
+ 'MemoryOAuthStore.codeVerifier(): no PKCE verifier saved. The authorization flow must call saveCodeVerifier before requesting the verifier.',
132
+ )
133
+ }
134
+ return this._verifier
135
+ }
136
+ saveCodeVerifier(verifier: string): void {
137
+ this._verifier = verifier
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Thrown when an MCP server requires the user to authorize before
143
+ * the framework can connect. Apps catch this on `MCPClient.connect()`,
144
+ * redirect the user to `authorizationUrl`, and on the OAuth callback
145
+ * route call `MCPClient.completeAuthorization(code)` to finish the
146
+ * flow.
147
+ *
148
+ * `BrainError` subclass so the typed-exception handler renders it
149
+ * cleanly through the standard pathway.
150
+ */
151
+ export class MCPAuthRequiredError extends BrainError {
152
+ /** URL the user should be redirected to in order to authorize. */
153
+ readonly authorizationUrl: string
154
+
155
+ constructor(serverName: string, authorizationUrl: string) {
156
+ super(
157
+ `MCPClient(${serverName}): authorization required. Redirect the user to authorizationUrl and call completeAuthorization(code) from your callback route.`,
158
+ {
159
+ context: { server: serverName, authorizationUrl },
160
+ },
161
+ )
162
+ this.authorizationUrl = authorizationUrl
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Default client metadata used when dynamic client registration
168
+ * fires. Apps override per-server via
169
+ * `MCPOAuthConfig.clientMetadata`.
170
+ */
171
+ function defaultClientMetadata(redirectUri: string): OAuthClientMetadata {
172
+ return {
173
+ redirect_uris: [redirectUri],
174
+ token_endpoint_auth_method: 'none',
175
+ grant_types: ['authorization_code', 'refresh_token'],
176
+ response_types: ['code'],
177
+ client_name: 'strav-brain',
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Internal — implements the SDK's `OAuthClientProvider` against an
183
+ * `MCPOAuthStore`. Holds the auth URL captured from the SDK so
184
+ * `MCPClient.connect()` can surface it on `MCPAuthRequiredError`.
185
+ */
186
+ export class StoreBackedOAuthProvider implements OAuthClientProvider {
187
+ /** Captured auth URL — populated by the SDK when authorization is needed. */
188
+ capturedAuthorizationUrl: URL | undefined
189
+
190
+ constructor(
191
+ private readonly config: MCPOAuthConfig,
192
+ ) {}
193
+
194
+ get redirectUrl(): string {
195
+ return this.config.redirectUri
196
+ }
197
+
198
+ get clientMetadata(): OAuthClientMetadata {
199
+ return {
200
+ ...defaultClientMetadata(this.config.redirectUri),
201
+ ...(this.config.scope !== undefined ? { scope: this.config.scope } : {}),
202
+ ...(this.config.clientMetadata ?? {}),
203
+ }
204
+ }
205
+
206
+ async clientInformation(): Promise<OAuthClientInformation | undefined> {
207
+ return await this.config.store.clientInformation()
208
+ }
209
+ async saveClientInformation(info: OAuthClientInformationFull): Promise<void> {
210
+ await this.config.store.saveClientInformation(info)
211
+ }
212
+ async tokens(): Promise<OAuthTokens | undefined> {
213
+ return await this.config.store.tokens()
214
+ }
215
+ async saveTokens(tokens: OAuthTokens): Promise<void> {
216
+ await this.config.store.saveTokens(tokens)
217
+ }
218
+ async redirectToAuthorization(authorizationUrl: URL): Promise<void> {
219
+ this.capturedAuthorizationUrl = authorizationUrl
220
+ }
221
+ async saveCodeVerifier(verifier: string): Promise<void> {
222
+ await this.config.store.saveCodeVerifier(verifier)
223
+ }
224
+ async codeVerifier(): Promise<string> {
225
+ return await this.config.store.codeVerifier()
226
+ }
227
+ }
@@ -75,8 +75,12 @@ function buildTool(
75
75
  name: `${serverName}${NAME_SEPARATOR}${descriptor.name}`,
76
76
  description: descriptor.description,
77
77
  inputSchema: descriptor.inputSchema,
78
- async execute(input: unknown, _ctx: ToolContext): Promise<string> {
79
- const result = await client.callTool(descriptor.name, input)
78
+ async execute(input: unknown, ctx: ToolContext): Promise<string> {
79
+ const result = await client.callTool(
80
+ descriptor.name,
81
+ input,
82
+ ctx.signal !== undefined ? { signal: ctx.signal } : {},
83
+ )
80
84
  if (result.isError) {
81
85
  return `MCP tool error: ${result.content}`
82
86
  }
package/src/mcp_server.ts CHANGED
@@ -40,8 +40,24 @@ export interface MCPServer {
40
40
  * Optional bearer token. Apps source from env vars / secrets
41
41
  * managers — never hardcode. The framework forwards this verbatim
42
42
  * to the provider's `authorization_token` field.
43
+ *
44
+ * Mutually exclusive with `oauth`. Use `authorizationToken` for
45
+ * self-hosted servers where you control the token; use `oauth`
46
+ * for commercial servers (Linear, Notion, GitHub, ...).
43
47
  */
44
48
  authorizationToken?: string
49
+ /**
50
+ * OAuth configuration for servers that require it. When set, the
51
+ * local MCP client (`@strav/brain/mcp`) drives the
52
+ * authorization-code-with-PKCE flow against the server's OAuth
53
+ * endpoints, storing tokens via the supplied `store`. The
54
+ * Anthropic server-side path doesn't use this — Anthropic's
55
+ * connector handles its own auth.
56
+ *
57
+ * Mutually exclusive with `authorizationToken`. See the OAuth
58
+ * section of `docs/brain/guides/mcp.md`.
59
+ */
60
+ oauth?: import('./mcp/oauth.ts').MCPOAuthConfig
45
61
  /** Per-server tool config (allowlist / enable flag). */
46
62
  tools?: MCPServerToolConfig
47
63
  }
package/src/provider.ts CHANGED
@@ -12,16 +12,24 @@
12
12
  * subclassing.
13
13
  */
14
14
 
15
+ import type { AgentGenerateResult } from './agent_generate_result.ts'
15
16
  import type { AgentResult } from './agent_result.ts'
17
+ import type { AgentStreamEvent } from './agent_stream_event.ts'
16
18
  import type { MCPServer } from './mcp_server.ts'
17
19
  import type { OutputSchema } from './output_schema.ts'
18
20
  import type { Tool } from './tool.ts'
21
+ import type { ToolExecutionError } from './tool_execution_error.ts'
19
22
  import type {
23
+ AudioSource,
20
24
  ChatOptions,
21
25
  ChatResult,
26
+ EmbedOptions,
27
+ EmbedResult,
22
28
  GenerateResult,
23
29
  Message,
24
30
  StreamEvent,
31
+ TranscribeOptions,
32
+ TranscribeResult,
25
33
  } from './types.ts'
26
34
 
27
35
  export interface RunWithToolsOptions extends ChatOptions {
@@ -37,6 +45,30 @@ export interface RunWithToolsOptions extends ChatOptions {
37
45
  * resulting `mcp_tool_use` / `mcp_tool_result` blocks.
38
46
  */
39
47
  mcpServers?: readonly MCPServer[]
48
+ /**
49
+ * Tool-error recovery hook. Called when a tool's `execute` throws
50
+ * — OR when the model called a tool that isn't registered. Two
51
+ * outcomes:
52
+ *
53
+ * - Return a string → the loop continues. The string lands as
54
+ * `tool_result.content` with `isError: true`, the model sees
55
+ * the error and can adapt (try a different approach, ask the
56
+ * user, give up). Recommended for production agents that
57
+ * should survive transient failures.
58
+ *
59
+ * - Return `undefined` (the default when this option is unset)
60
+ * → the framework throws `ToolExecutionError` and the loop
61
+ * aborts. Same behavior as before this option existed.
62
+ *
63
+ * The hook may inspect `error.cause` to filter — e.g., feed back
64
+ * transient HTTP errors but rethrow programmer errors:
65
+ *
66
+ * ```ts
67
+ * onToolError: (err) =>
68
+ * err.cause instanceof TransientError ? err.cause.message : undefined
69
+ * ```
70
+ */
71
+ onToolError?(error: ToolExecutionError): string | undefined
40
72
  }
41
73
 
42
74
  export interface Provider {
@@ -99,4 +131,81 @@ export interface Provider {
99
131
  schema: OutputSchema<T>,
100
132
  options?: ChatOptions,
101
133
  ): Promise<GenerateResult<T>>
134
+
135
+ /**
136
+ * Tool-loop + structured output combined. Runs the agentic loop
137
+ * with the same tool-handling as `runWithTools`, but pins a
138
+ * JSON-Schema constraint on every turn — so when the model
139
+ * finally answers without calling a tool, its text is JSON
140
+ * matching the schema. Returns the parsed value alongside the
141
+ * loop bookkeeping.
142
+ *
143
+ * Optional on the interface; `BrainManager.generateWithTools`
144
+ * throws `BrainError` when the configured provider lacks it.
145
+ */
146
+ runWithToolsAndSchema?<T>(
147
+ messages: readonly Message[],
148
+ tools: readonly Tool[],
149
+ schema: OutputSchema<T>,
150
+ options?: RunWithToolsOptions,
151
+ ): Promise<AgentGenerateResult<T>>
152
+
153
+ /**
154
+ * Streaming variant of `runWithToolsAndSchema`. Same agentic loop,
155
+ * same schema constraint on every turn — yielded as
156
+ * `AgentStreamEvent<T>`s. The terminal `stop` event carries the
157
+ * parsed `value` + raw `text` alongside the loop bookkeeping.
158
+ *
159
+ * Optional; `BrainManager.streamGenerateWithTools` throws
160
+ * `BrainError` when the chosen provider doesn't implement it.
161
+ */
162
+ streamWithToolsAndSchema?<T>(
163
+ messages: readonly Message[],
164
+ tools: readonly Tool[],
165
+ schema: OutputSchema<T>,
166
+ options?: RunWithToolsOptions,
167
+ ): AsyncIterable<AgentStreamEvent<T>>
168
+
169
+ /**
170
+ * Streaming variant of `runWithTools`. Yields `AgentStreamEvent`s
171
+ * as the loop progresses — text deltas during model turns,
172
+ * `tool_use` / `tool_result` boundaries around tool execution,
173
+ * `iteration_start` / `iteration_end` per round, a terminal
174
+ * `stop` with the full trace + usage.
175
+ *
176
+ * Optional — providers without a streaming tool-loop implementation
177
+ * can omit it; `BrainManager.streamTools` throws `BrainError` in
178
+ * that case.
179
+ */
180
+ streamWithTools?(
181
+ messages: readonly Message[],
182
+ tools: readonly Tool[],
183
+ options?: RunWithToolsOptions,
184
+ ): AsyncIterable<AgentStreamEvent>
185
+
186
+ /**
187
+ * Embeddings — turn one or more text inputs into vectors for
188
+ * similarity search / RAG / clustering. Optional because not
189
+ * every provider exposes an embeddings endpoint (V1: Anthropic
190
+ * and DeepSeek don't; OpenAI, Gemini, Ollama do).
191
+ */
192
+ embed?(
193
+ texts: readonly string[],
194
+ options?: EmbedOptions,
195
+ ): Promise<EmbedResult>
196
+
197
+ /**
198
+ * Audio transcription — convert an audio clip to text.
199
+ * Complements `AudioBlock` (which sends audio + text together
200
+ * to a multimodal chat model) by exposing the dedicated
201
+ * transcription endpoint where the provider has one. V1:
202
+ * OpenAI (Whisper / gpt-4o-transcribe), Ollama (inherits via
203
+ * OpenAI-compat), Gemini (chat-wrap fallback — internally
204
+ * sends an AudioBlock with a "transcribe verbatim" prompt).
205
+ * Anthropic + DeepSeek throw — no transcription API.
206
+ */
207
+ transcribe?(
208
+ audio: AudioSource,
209
+ options?: TranscribeOptions,
210
+ ): Promise<TranscribeResult>
102
211
  }