@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.
- package/package.json +23 -7
- package/src/agent.ts +43 -5
- package/src/agent_generate_result.ts +32 -0
- package/src/agent_result.ts +7 -0
- package/src/agent_runner.ts +218 -14
- package/src/agent_stream_event.ts +100 -0
- package/src/brain_config.ts +218 -1
- package/src/brain_driver.ts +247 -0
- package/src/brain_error.ts +86 -10
- package/src/brain_manager.ts +359 -11
- package/src/brain_provider.ts +79 -9
- package/src/drivers/anthropic/anthropic_brain_driver.ts +641 -0
- package/src/drivers/anthropic/anthropic_helpers.ts +65 -0
- package/src/drivers/anthropic/anthropic_message_builder.ts +258 -0
- package/src/drivers/anthropic/anthropic_response_mapper.ts +123 -0
- package/src/drivers/anthropic/anthropic_tool_loop.ts +246 -0
- package/src/drivers/anthropic/index.ts +1 -0
- package/src/drivers/deepseek/deepseek_brain_driver.ts +117 -0
- package/src/drivers/deepseek/index.ts +1 -0
- package/src/drivers/gemini/gemini_brain_driver.ts +1064 -0
- package/src/drivers/gemini/index.ts +1 -0
- package/src/drivers/minimax/index.ts +1 -0
- package/src/drivers/minimax/minimax_brain_driver.ts +84 -0
- package/src/drivers/ollama/index.ts +1 -0
- package/src/drivers/ollama/ollama_brain_driver.ts +86 -0
- package/src/drivers/openai/index.ts +1 -0
- package/src/drivers/openai/openai_brain_driver.ts +796 -0
- package/src/drivers/openai/openai_helpers.ts +58 -0
- package/src/drivers/openai/openai_message_builder.ts +187 -0
- package/src/drivers/openai/openai_response_mapper.ts +70 -0
- package/src/drivers/openai/openai_tool_dispatch.ts +127 -0
- package/src/drivers/openai/openai_tool_loop.ts +191 -0
- package/src/drivers/openai_compat/index.ts +1 -0
- package/src/drivers/openai_compat/openai_compat_brain_driver.ts +616 -0
- package/src/drivers/openai_responses/index.ts +1 -0
- package/src/drivers/openai_responses/openai_responses_brain_driver.ts +1015 -0
- package/src/drivers/openrouter/index.ts +1 -0
- package/src/drivers/openrouter/openrouter_brain_driver.ts +137 -0
- package/src/drivers/qwen/index.ts +1 -0
- package/src/drivers/qwen/qwen_brain_driver.ts +103 -0
- package/src/index.ts +75 -11
- package/src/mcp/client.ts +243 -0
- package/src/mcp/index.ts +23 -0
- package/src/mcp/oauth.ts +227 -0
- package/src/mcp/pool.ts +106 -0
- package/src/mcp/resolve_mcp_tools.ts +108 -0
- package/src/mcp_server.ts +63 -0
- package/src/output_schema.ts +72 -0
- package/src/persistence/brain_message.ts +34 -0
- package/src/persistence/brain_message_repository.ts +98 -0
- package/src/persistence/brain_store.ts +166 -0
- package/src/persistence/brain_suspended_run.ts +30 -0
- package/src/persistence/brain_suspended_run_repository.ts +59 -0
- package/src/persistence/brain_thread.ts +30 -0
- package/src/persistence/brain_thread_repository.ts +56 -0
- package/src/persistence/database_brain_store.ts +190 -0
- package/src/persistence/index.ts +48 -0
- package/src/persistence/schemas/brain_message_schema.ts +61 -0
- package/src/persistence/schemas/brain_suspended_run_schema.ts +58 -0
- package/src/persistence/schemas/brain_thread_schema.ts +50 -0
- package/src/persistence/schemas/index.ts +3 -0
- package/src/suspended_run.ts +153 -0
- package/src/thread.ts +40 -1
- package/src/tool.ts +7 -0
- package/src/tool_runner.ts +81 -0
- package/src/translate/index.ts +19 -0
- package/src/translate/translate_cache.ts +78 -0
- package/src/translate/translate_provider.ts +46 -0
- package/src/translate/translator.ts +271 -0
- package/src/types.ts +398 -1
- package/src/zod/index.ts +121 -0
- package/src/provider.ts +0 -74
- package/src/providers/anthropic_provider.ts +0 -397
package/src/mcp/oauth.ts
ADDED
|
@@ -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
|
+
}
|
package/src/mcp/pool.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `MCPClientPool` — long-lived, per-server `MCPClient` cache.
|
|
3
|
+
*
|
|
4
|
+
* Default `resolveMcpTools` flow constructs a fresh `MCPClient` per
|
|
5
|
+
* call to `runTools` / `runWithTools` / etc., handshakes the
|
|
6
|
+
* Streamable HTTP transport, lists tools, executes them, then
|
|
7
|
+
* closes the transport in a `finally`. For one-shot calls that's
|
|
8
|
+
* fine. For long-running agent workers — chat servers, background
|
|
9
|
+
* job processors — the per-call handshake adds noticeable
|
|
10
|
+
* latency and burns connection slots upstream.
|
|
11
|
+
*
|
|
12
|
+
* The pool keeps one connected `MCPClient` per `(server.name,
|
|
13
|
+
* server.url)` pair for the lifetime of the pool. `borrow(server)`
|
|
14
|
+
* returns the pooled client (lazily creating + connecting on
|
|
15
|
+
* first use). When the pool is in play, `resolveMcpTools` skips
|
|
16
|
+
* the per-call `close()` — the pool owns the lifetime — so
|
|
17
|
+
* subsequent calls reuse the existing transport.
|
|
18
|
+
*
|
|
19
|
+
* Apps own the pool's lifetime. Construct one at app boot, hand it
|
|
20
|
+
* to every provider (or to `BrainProvider` if using the DI
|
|
21
|
+
* helper), and call `pool.close()` on shutdown.
|
|
22
|
+
*
|
|
23
|
+
* ```ts
|
|
24
|
+
* const pool = new MCPClientPool()
|
|
25
|
+
*
|
|
26
|
+
* const openai = new OpenAIBrainDriver(
|
|
27
|
+
* 'openai',
|
|
28
|
+
* { driver: 'openai', apiKey: ... },
|
|
29
|
+
* { mcpPool: pool },
|
|
30
|
+
* )
|
|
31
|
+
*
|
|
32
|
+
* // ... many runTools calls later, on graceful shutdown:
|
|
33
|
+
* await pool.close()
|
|
34
|
+
* ```
|
|
35
|
+
*
|
|
36
|
+
* Concurrency: `borrow()` is synchronous; `MCPClient.connect()`
|
|
37
|
+
* itself dedupes concurrent calls. Two parallel `runTools` calls
|
|
38
|
+
* sharing the same pooled client both await one handshake.
|
|
39
|
+
*
|
|
40
|
+
* Re-auth: when a borrowed client throws `MCPAuthRequiredError`,
|
|
41
|
+
* the pool keeps the (still un-authorized) client. Apps call
|
|
42
|
+
* `pool.evict(server)` after running `completeAuthorization` on
|
|
43
|
+
* a fresh client so subsequent borrows see the renewed state —
|
|
44
|
+
* or just reuse the same client the app authorized via the
|
|
45
|
+
* standard `MCPClient.completeAuthorization` flow.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
import type { MCPServer } from '../mcp_server.ts'
|
|
49
|
+
import { MCPClient } from './client.ts'
|
|
50
|
+
|
|
51
|
+
/** Internal — factory injection for tests. Defaults to `new MCPClient(server)`. */
|
|
52
|
+
export type MCPClientFactory = (server: MCPServer) => MCPClient
|
|
53
|
+
|
|
54
|
+
export class MCPClientPool {
|
|
55
|
+
private readonly clients: Map<string, MCPClient> = new Map()
|
|
56
|
+
private readonly factory: MCPClientFactory
|
|
57
|
+
|
|
58
|
+
constructor(factory: MCPClientFactory = (s) => new MCPClient(s)) {
|
|
59
|
+
this.factory = factory
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Return the pooled client for `server`, constructing + caching it on
|
|
64
|
+
* first call. The client is NOT eagerly connected — the first
|
|
65
|
+
* `listTools` / `callTool` invocation triggers `connect()` once.
|
|
66
|
+
*/
|
|
67
|
+
borrow(server: MCPServer): MCPClient {
|
|
68
|
+
const key = poolKey(server)
|
|
69
|
+
const existing = this.clients.get(key)
|
|
70
|
+
if (existing) return existing
|
|
71
|
+
const client = this.factory(server)
|
|
72
|
+
this.clients.set(key, client)
|
|
73
|
+
return client
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Drop the cached client for `server` and close its transport.
|
|
78
|
+
* Useful after the app re-authorizes an OAuth server, or after a
|
|
79
|
+
* transient failure where the connection state is suspect and a
|
|
80
|
+
* fresh handshake on next borrow is preferable.
|
|
81
|
+
*/
|
|
82
|
+
async evict(server: MCPServer): Promise<void> {
|
|
83
|
+
const key = poolKey(server)
|
|
84
|
+
const client = this.clients.get(key)
|
|
85
|
+
if (!client) return
|
|
86
|
+
this.clients.delete(key)
|
|
87
|
+
await client.close()
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Close every pooled client. Call on app shutdown. */
|
|
91
|
+
async close(): Promise<void> {
|
|
92
|
+
const all = [...this.clients.values()]
|
|
93
|
+
this.clients.clear()
|
|
94
|
+
await Promise.all(all.map((c) => c.close()))
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Whether the pool currently holds a client for `server`. Used by tests. */
|
|
98
|
+
has(server: MCPServer): boolean {
|
|
99
|
+
return this.clients.has(poolKey(server))
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Pool key: name + url, so two `MCPServer`s with the same name but different URLs don't collide. */
|
|
104
|
+
function poolKey(server: MCPServer): string {
|
|
105
|
+
return `${server.name}|${server.url}`
|
|
106
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `resolveMcpTools` — connects to each `MCPServer`, discovers its
|
|
3
|
+
* tools, and surfaces them as framework `Tool`s the standard agentic
|
|
4
|
+
* loop already knows how to invoke.
|
|
5
|
+
*
|
|
6
|
+
* Honors the per-server config in `MCPServer.tools`:
|
|
7
|
+
* - `enabled: false` → server is skipped entirely.
|
|
8
|
+
* - `allowedTools` → only those tool names are exposed.
|
|
9
|
+
*
|
|
10
|
+
* Naming: discovered tools are namespaced as `<server>__<tool>` to
|
|
11
|
+
* keep names unique when multiple servers expose overlapping names.
|
|
12
|
+
* The `Tool.execute` then routes back to the correct server. The
|
|
13
|
+
* underscore separator (not `.` or `/`) is chosen because OpenAI's
|
|
14
|
+
* tool-name regex rejects `.` and `/`.
|
|
15
|
+
*
|
|
16
|
+
* Lifecycle: this helper returns `{ tools, close }`. `close` runs all
|
|
17
|
+
* client `close()` calls in parallel — providers must call it in a
|
|
18
|
+
* `finally` to avoid leaking transports.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { MCPServer } from '../mcp_server.ts'
|
|
22
|
+
import type { Tool, ToolContext } from '../tool.ts'
|
|
23
|
+
import { MCPClient } from './client.ts'
|
|
24
|
+
import type { MCPClientPool } from './pool.ts'
|
|
25
|
+
|
|
26
|
+
export interface ResolvedMcpTools {
|
|
27
|
+
tools: Tool[]
|
|
28
|
+
close(): Promise<void>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ResolveMcpToolsOptions {
|
|
32
|
+
/** Override the client factory — tests inject mock clients per server here. */
|
|
33
|
+
clientFactory?(server: MCPServer): MCPClient
|
|
34
|
+
/**
|
|
35
|
+
* When set, clients are borrowed from the pool instead of being
|
|
36
|
+
* constructed fresh per call, and the returned `close` becomes a
|
|
37
|
+
* no-op — the pool owns the lifetime, and apps call
|
|
38
|
+
* `pool.close()` on shutdown. Mutually beneficial with
|
|
39
|
+
* `clientFactory` (tests pass a factory to the pool itself).
|
|
40
|
+
*/
|
|
41
|
+
pool?: MCPClientPool
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const NAME_SEPARATOR = '__'
|
|
45
|
+
|
|
46
|
+
export async function resolveMcpTools(
|
|
47
|
+
servers: readonly MCPServer[],
|
|
48
|
+
options: ResolveMcpToolsOptions = {},
|
|
49
|
+
): Promise<ResolvedMcpTools> {
|
|
50
|
+
const clients: MCPClient[] = []
|
|
51
|
+
const tools: Tool[] = []
|
|
52
|
+
const pooled = options.pool !== undefined
|
|
53
|
+
|
|
54
|
+
for (const server of servers) {
|
|
55
|
+
if (server.tools?.enabled === false) continue
|
|
56
|
+
const client = options.pool
|
|
57
|
+
? options.pool.borrow(server)
|
|
58
|
+
: options.clientFactory
|
|
59
|
+
? options.clientFactory(server)
|
|
60
|
+
: new MCPClient(server)
|
|
61
|
+
if (!pooled) clients.push(client)
|
|
62
|
+
|
|
63
|
+
const allowed = server.tools?.allowedTools
|
|
64
|
+
const allowedSet = allowed ? new Set(allowed) : null
|
|
65
|
+
|
|
66
|
+
const descriptors = await client.listTools()
|
|
67
|
+
for (const descriptor of descriptors) {
|
|
68
|
+
if (allowedSet && !allowedSet.has(descriptor.name)) continue
|
|
69
|
+
tools.push(buildTool(server.name, client, descriptor))
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
tools,
|
|
75
|
+
// Pooled clients live across calls — `close` becomes a no-op
|
|
76
|
+
// and the pool owns the lifetime. Non-pooled clients close
|
|
77
|
+
// here so each `runWithTools` invocation cleans up its own
|
|
78
|
+
// transports.
|
|
79
|
+
close: pooled
|
|
80
|
+
? async () => {}
|
|
81
|
+
: async () => {
|
|
82
|
+
await Promise.all(clients.map((c) => c.close()))
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function buildTool(
|
|
88
|
+
serverName: string,
|
|
89
|
+
client: MCPClient,
|
|
90
|
+
descriptor: { name: string; description: string; inputSchema: Record<string, unknown> },
|
|
91
|
+
): Tool {
|
|
92
|
+
return {
|
|
93
|
+
name: `${serverName}${NAME_SEPARATOR}${descriptor.name}`,
|
|
94
|
+
description: descriptor.description,
|
|
95
|
+
inputSchema: descriptor.inputSchema,
|
|
96
|
+
async execute(input: unknown, ctx: ToolContext): Promise<string> {
|
|
97
|
+
const result = await client.callTool(
|
|
98
|
+
descriptor.name,
|
|
99
|
+
input,
|
|
100
|
+
ctx.signal !== undefined ? { signal: ctx.signal } : {},
|
|
101
|
+
)
|
|
102
|
+
if (result.isError) {
|
|
103
|
+
return `MCP tool error: ${result.content}`
|
|
104
|
+
}
|
|
105
|
+
return result.content
|
|
106
|
+
},
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `MCPServer` — declarative configuration for a remote MCP server
|
|
3
|
+
* that Anthropic's backend invokes on behalf of the model.
|
|
4
|
+
*
|
|
5
|
+
* V1 leverages Anthropic's server-side MCP support: apps declare
|
|
6
|
+
* server URLs (with optional bearer auth) on the request; the
|
|
7
|
+
* backend connects to them, discovers their tools, surfaces them to
|
|
8
|
+
* the model, and runs the tool calls itself. The agentic loop here
|
|
9
|
+
* doesn't intercept MCP tool calls — they appear in the response as
|
|
10
|
+
* `MCPToolUseBlock` / `MCPToolResultBlock` content blocks for
|
|
11
|
+
* observability and audit-trail rendering.
|
|
12
|
+
*
|
|
13
|
+
* For OpenAI / Gemini / DeepSeek providers (later slices), a local
|
|
14
|
+
* MCP client implementation will live alongside this to translate
|
|
15
|
+
* MCP-discovered tools into `Tool` records and let the framework run
|
|
16
|
+
* the loop. The V1 contract stays the same; the per-provider
|
|
17
|
+
* implementation differs.
|
|
18
|
+
*
|
|
19
|
+
* `allowedTools` opts into a subset of the server's exposed tools —
|
|
20
|
+
* useful for narrowing surface area when the MCP server exposes more
|
|
21
|
+
* capabilities than the agent should be able to invoke. `enabled`
|
|
22
|
+
* defaults to `true`; set to `false` to declare the server without
|
|
23
|
+
* routing model calls to it (rare, but handy for temporary
|
|
24
|
+
* disablement without re-deploying config).
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
export interface MCPServerToolConfig {
|
|
28
|
+
/** Whitelist of tool names the agent can call. Omit for "all tools the server exposes." */
|
|
29
|
+
allowedTools?: readonly string[]
|
|
30
|
+
/** Default `true`. Set `false` to declare-but-disable. */
|
|
31
|
+
enabled?: boolean
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface MCPServer {
|
|
35
|
+
/** Server identifier — used in MCPToolUseBlock.serverName + logging. */
|
|
36
|
+
name: string
|
|
37
|
+
/** HTTPS URL of the MCP server. */
|
|
38
|
+
url: string
|
|
39
|
+
/**
|
|
40
|
+
* Optional bearer token. Apps source from env vars / secrets
|
|
41
|
+
* managers — never hardcode. The framework forwards this verbatim
|
|
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, ...).
|
|
47
|
+
*/
|
|
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
|
|
61
|
+
/** Per-server tool config (allowlist / enable flag). */
|
|
62
|
+
tools?: MCPServerToolConfig
|
|
63
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `OutputSchema<T>` — declarative description of a structured-output
|
|
3
|
+
* target. Apps pass one of these to `BrainManager.generate(...)` and
|
|
4
|
+
* get back a `GenerateResult<T>` whose `value` is the parsed JSON
|
|
5
|
+
* the model produced.
|
|
6
|
+
*
|
|
7
|
+
* Schema shape:
|
|
8
|
+
* - `name` — short identifier; OpenAI requires it on
|
|
9
|
+
* `response_format.json_schema.name`. Other providers ignore it
|
|
10
|
+
* but apps should still set something stable and meaningful for
|
|
11
|
+
* logs / telemetry.
|
|
12
|
+
* - `description` — optional hint the model sees (some providers
|
|
13
|
+
* surface it next to the schema; others embed it in the prompt
|
|
14
|
+
* translation).
|
|
15
|
+
* - `jsonSchema` — plain JSON Schema (draft 2020-12 compatible).
|
|
16
|
+
* The framework deliberately doesn't depend on Zod; apps that
|
|
17
|
+
* want Zod use `zod-to-json-schema` (or similar) at the call
|
|
18
|
+
* site and feed the result here.
|
|
19
|
+
* - `parse` — optional runtime validator/parser. When set, the
|
|
20
|
+
* framework runs every model response through it before
|
|
21
|
+
* returning. Apps that just want type-level inference (and
|
|
22
|
+
* trust the model + provider validation) can omit it; the
|
|
23
|
+
* return type is then `T` by type assertion only.
|
|
24
|
+
*
|
|
25
|
+
* Why no built-in Zod dep:
|
|
26
|
+
* Same reason `Tool.inputSchema` is plain JSON Schema — Strav
|
|
27
|
+
* doesn't pin apps to a schema library. A thin `@strav/brain-zod`
|
|
28
|
+
* helper that produces `OutputSchema<T>` from a Zod schema is
|
|
29
|
+
* straightforward to ship later without touching this file.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
export interface OutputSchema<T = unknown> {
|
|
33
|
+
/** Short identifier — provider-specific; some surface it, others ignore. */
|
|
34
|
+
name: string
|
|
35
|
+
/** Optional hint shown to the model alongside the schema. */
|
|
36
|
+
description?: string
|
|
37
|
+
/** JSON Schema describing the expected shape. */
|
|
38
|
+
jsonSchema: Record<string, unknown>
|
|
39
|
+
/** Optional runtime parser/validator. Apps that use Zod plug it in here. */
|
|
40
|
+
parse?(value: unknown): T
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Shared helper used by every provider's `generate` implementation:
|
|
45
|
+
* parse the raw JSON response, run the optional `parse` hook, and
|
|
46
|
+
* wrap parse failures in `BrainError` with the raw text on `.context`
|
|
47
|
+
* so apps can inspect what came back when validation rejects.
|
|
48
|
+
*/
|
|
49
|
+
import { BrainError } from './brain_error.ts'
|
|
50
|
+
|
|
51
|
+
export function parseGenerated<T>(text: string, schema: OutputSchema<T>): T {
|
|
52
|
+
let parsed: unknown
|
|
53
|
+
try {
|
|
54
|
+
parsed = JSON.parse(text)
|
|
55
|
+
} catch (cause) {
|
|
56
|
+
throw new BrainError(
|
|
57
|
+
`BrainProvider.generate: response was not valid JSON for schema "${schema.name}".`,
|
|
58
|
+
{ context: { schema: schema.name, text }, cause },
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
if (schema.parse) {
|
|
62
|
+
try {
|
|
63
|
+
return schema.parse(parsed)
|
|
64
|
+
} catch (cause) {
|
|
65
|
+
throw new BrainError(
|
|
66
|
+
`BrainProvider.generate: response failed schema.parse for "${schema.name}".`,
|
|
67
|
+
{ context: { schema: schema.name, text }, cause },
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return parsed as T
|
|
72
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `BrainMessage` — the typed row of `brain_message`. One per turn.
|
|
3
|
+
*
|
|
4
|
+
* `content` mirrors `Message.content` — string for plain text or
|
|
5
|
+
* `ContentBlock[]` when the turn carries structured blocks
|
|
6
|
+
* (tool_use, tool_result, image, compaction, ...). JSONB hydration
|
|
7
|
+
* is automatic.
|
|
8
|
+
*
|
|
9
|
+
* Assistant turns carry `model` / `usage` / `stop_reason` /
|
|
10
|
+
* `response_id`; user turns leave them NULL. The repository's
|
|
11
|
+
* `appendTurn` helper writes the right shape per role.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { Model } from '@strav/database'
|
|
15
|
+
import type { ChatUsage, ContentBlock } from '../types.ts'
|
|
16
|
+
import { brainMessageSchema } from './schemas/brain_message_schema.ts'
|
|
17
|
+
|
|
18
|
+
export type BrainMessageRole = 'user' | 'assistant'
|
|
19
|
+
|
|
20
|
+
export class BrainMessage extends Model {
|
|
21
|
+
static override readonly schema = brainMessageSchema
|
|
22
|
+
|
|
23
|
+
id!: string
|
|
24
|
+
tenant_id!: string
|
|
25
|
+
thread_id!: string
|
|
26
|
+
turn_index!: number
|
|
27
|
+
role!: BrainMessageRole
|
|
28
|
+
content!: string | ContentBlock[]
|
|
29
|
+
model!: string | null
|
|
30
|
+
usage!: ChatUsage | null
|
|
31
|
+
stop_reason!: string | null
|
|
32
|
+
response_id!: string | null
|
|
33
|
+
created_at!: Date
|
|
34
|
+
}
|