@strav/brain 1.0.0-alpha.16 → 1.0.0-alpha.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -2
- package/src/agent.ts +34 -5
- package/src/agent_generate_result.ts +2 -0
- package/src/agent_result.ts +7 -0
- package/src/agent_runner.ts +134 -15
- package/src/agent_stream_event.ts +100 -0
- package/src/brain_config.ts +91 -1
- package/src/brain_manager.ts +287 -6
- package/src/brain_provider.ts +25 -1
- package/src/index.ts +37 -2
- package/src/mcp/client.ts +99 -13
- package/src/mcp/index.ts +7 -0
- package/src/mcp/oauth.ts +227 -0
- package/src/mcp/pool.ts +106 -0
- package/src/mcp/resolve_mcp_tools.ts +31 -9
- package/src/mcp_server.ts +16 -0
- package/src/persistence/brain_message.ts +34 -0
- package/src/persistence/brain_message_repository.ts +106 -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 +68 -0
- package/src/persistence/brain_thread.ts +30 -0
- package/src/persistence/brain_thread_repository.ts +65 -0
- package/src/persistence/database_brain_store.ts +190 -0
- package/src/persistence/index.ts +48 -0
- package/src/persistence/schema/brain_message_schema.ts +61 -0
- package/src/persistence/schema/brain_suspended_run_schema.ts +58 -0
- package/src/persistence/schema/brain_thread_schema.ts +50 -0
- package/src/persistence/schema/index.ts +3 -0
- package/src/provider.ts +145 -1
- package/src/providers/anthropic_provider.ts +723 -38
- package/src/providers/deepseek_provider.ts +117 -0
- package/src/providers/gemini_provider.ts +625 -33
- package/src/providers/ollama_provider.ts +86 -0
- package/src/providers/openai_compat_provider.ts +616 -0
- package/src/providers/openai_provider.ts +801 -43
- package/src/providers/openai_responses_provider.ts +1015 -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/types.ts +343 -0
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 OpenAIProvider(
|
|
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
|
+
}
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
import type { MCPServer } from '../mcp_server.ts'
|
|
22
22
|
import type { Tool, ToolContext } from '../tool.ts'
|
|
23
23
|
import { MCPClient } from './client.ts'
|
|
24
|
+
import type { MCPClientPool } from './pool.ts'
|
|
24
25
|
|
|
25
26
|
export interface ResolvedMcpTools {
|
|
26
27
|
tools: Tool[]
|
|
@@ -30,6 +31,14 @@ export interface ResolvedMcpTools {
|
|
|
30
31
|
export interface ResolveMcpToolsOptions {
|
|
31
32
|
/** Override the client factory — tests inject mock clients per server here. */
|
|
32
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
|
|
33
42
|
}
|
|
34
43
|
|
|
35
44
|
const NAME_SEPARATOR = '__'
|
|
@@ -40,13 +49,16 @@ export async function resolveMcpTools(
|
|
|
40
49
|
): Promise<ResolvedMcpTools> {
|
|
41
50
|
const clients: MCPClient[] = []
|
|
42
51
|
const tools: Tool[] = []
|
|
52
|
+
const pooled = options.pool !== undefined
|
|
43
53
|
|
|
44
54
|
for (const server of servers) {
|
|
45
55
|
if (server.tools?.enabled === false) continue
|
|
46
|
-
const client = options.
|
|
47
|
-
? options.
|
|
48
|
-
:
|
|
49
|
-
|
|
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)
|
|
50
62
|
|
|
51
63
|
const allowed = server.tools?.allowedTools
|
|
52
64
|
const allowedSet = allowed ? new Set(allowed) : null
|
|
@@ -60,9 +72,15 @@ export async function resolveMcpTools(
|
|
|
60
72
|
|
|
61
73
|
return {
|
|
62
74
|
tools,
|
|
63
|
-
close
|
|
64
|
-
|
|
65
|
-
|
|
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
|
+
},
|
|
66
84
|
}
|
|
67
85
|
}
|
|
68
86
|
|
|
@@ -75,8 +93,12 @@ function buildTool(
|
|
|
75
93
|
name: `${serverName}${NAME_SEPARATOR}${descriptor.name}`,
|
|
76
94
|
description: descriptor.description,
|
|
77
95
|
inputSchema: descriptor.inputSchema,
|
|
78
|
-
async execute(input: unknown,
|
|
79
|
-
const result = await client.callTool(
|
|
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
|
+
)
|
|
80
102
|
if (result.isError) {
|
|
81
103
|
return `MCP tool error: ${result.content}`
|
|
82
104
|
}
|
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
|
}
|
|
@@ -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 './schema/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
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `BrainMessageRepository` — data-access object for `BrainMessage`.
|
|
3
|
+
*
|
|
4
|
+
* Append-only by design: messages get inserted and never updated.
|
|
5
|
+
* `appendTurn` handles the next-`turn_index` lookup + INSERT in a
|
|
6
|
+
* single round-trip via `INSERT ... SELECT COALESCE(MAX, -1) + 1`
|
|
7
|
+
* so concurrent appends on the same thread don't race.
|
|
8
|
+
*
|
|
9
|
+
* Reads:
|
|
10
|
+
* - `loadForThread(threadId, opts?)` — paginated history,
|
|
11
|
+
* ordered by `turn_index ASC`.
|
|
12
|
+
* - `countForThread(threadId)` — total turn count, useful for
|
|
13
|
+
* pagination UIs.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// biome-ignore lint/style/useImportType: PostgresDatabase needs to be a value import for @inject().
|
|
17
|
+
import { PostgresDatabase, quoteIdent, Repository, type RepositoryScope } from '@strav/database'
|
|
18
|
+
// biome-ignore lint/style/useImportType: EventBus value import for @inject().
|
|
19
|
+
import { EventBus, inject, ulid } from '@strav/kernel'
|
|
20
|
+
import type { ChatUsage, ContentBlock } from '../types.ts'
|
|
21
|
+
import { BrainMessage, type BrainMessageRole } from './brain_message.ts'
|
|
22
|
+
import { brainMessageSchema } from './schema/brain_message_schema.ts'
|
|
23
|
+
|
|
24
|
+
export interface AppendTurnInput {
|
|
25
|
+
threadId: string
|
|
26
|
+
role: BrainMessageRole
|
|
27
|
+
content: string | ContentBlock[]
|
|
28
|
+
model?: string
|
|
29
|
+
usage?: ChatUsage
|
|
30
|
+
stopReason?: string
|
|
31
|
+
responseId?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface LoadMessagesOptions {
|
|
35
|
+
/** Pagination — defaults to no limit (full history). */
|
|
36
|
+
limit?: number
|
|
37
|
+
offset?: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
@inject()
|
|
41
|
+
export class BrainMessageRepository extends Repository<BrainMessage> {
|
|
42
|
+
static override readonly schema = brainMessageSchema
|
|
43
|
+
static override readonly model = BrainMessage
|
|
44
|
+
|
|
45
|
+
// biome-ignore lint/complexity/noUselessConstructor: explicit constructor for @inject() metadata emission.
|
|
46
|
+
constructor(db: PostgresDatabase, events: EventBus) {
|
|
47
|
+
super(db, events)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Insert a new turn at the next `turn_index` for the thread. The
|
|
52
|
+
* `turn_index` is computed in-SQL so two concurrent appends
|
|
53
|
+
* don't collide — the unique `(thread_id, turn_index)` index on
|
|
54
|
+
* the table catches any race that slips through.
|
|
55
|
+
*
|
|
56
|
+
* Lifecycle: routes through `create()` so `brain_message.created`
|
|
57
|
+
* events fire. The `turn_index` is filled in by the SELECT side
|
|
58
|
+
* of an explicit INSERT here rather than `create()` because the
|
|
59
|
+
* value isn't known client-side.
|
|
60
|
+
*/
|
|
61
|
+
async appendTurn(input: AppendTurnInput, opts?: RepositoryScope): Promise<BrainMessage> {
|
|
62
|
+
const table = quoteIdent(brainMessageSchema.name)
|
|
63
|
+
const sql = `
|
|
64
|
+
INSERT INTO ${table}
|
|
65
|
+
("id", "thread_id", "turn_index", "role", "content",
|
|
66
|
+
"model", "usage", "stop_reason", "response_id", "created_at")
|
|
67
|
+
SELECT
|
|
68
|
+
$1, $2,
|
|
69
|
+
COALESCE((SELECT MAX("turn_index") FROM ${table} WHERE "thread_id" = $2), -1) + 1,
|
|
70
|
+
$3, $4::jsonb, $5, $6::jsonb, $7, $8, NOW()
|
|
71
|
+
RETURNING *
|
|
72
|
+
`
|
|
73
|
+
const params = [
|
|
74
|
+
ulid(),
|
|
75
|
+
input.threadId,
|
|
76
|
+
input.role,
|
|
77
|
+
JSON.stringify(input.content),
|
|
78
|
+
input.model ?? null,
|
|
79
|
+
input.usage !== undefined ? JSON.stringify(input.usage) : null,
|
|
80
|
+
input.stopReason ?? null,
|
|
81
|
+
input.responseId ?? null,
|
|
82
|
+
]
|
|
83
|
+
const rows = await this.executor(opts).query<Record<string, unknown>>(sql, params)
|
|
84
|
+
if (rows.length === 0) {
|
|
85
|
+
throw new Error('BrainMessageRepository.appendTurn: INSERT returned no rows.')
|
|
86
|
+
}
|
|
87
|
+
return this.hydrate(rows[0]!)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Load every turn for a thread, ordered by `turn_index ASC`. */
|
|
91
|
+
async loadForThread(
|
|
92
|
+
threadId: string,
|
|
93
|
+
opts: LoadMessagesOptions = {},
|
|
94
|
+
): Promise<BrainMessage[]> {
|
|
95
|
+
let q = this.query().where('thread_id', threadId).orderBy('turn_index', 'asc')
|
|
96
|
+
if (opts.limit !== undefined) q = q.limit(opts.limit)
|
|
97
|
+
if (opts.offset !== undefined) q = q.offset(opts.offset)
|
|
98
|
+
return q.get()
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Total turn count for a thread — useful for pagination UIs. */
|
|
102
|
+
async countForThread(threadId: string): Promise<number> {
|
|
103
|
+
return this.count({ thread_id: threadId } as Partial<BrainMessage>)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|