@strav/brain 1.0.0-alpha.11 → 1.0.0-alpha.12

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 CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@strav/brain",
3
- "version": "1.0.0-alpha.11",
3
+ "version": "1.0.0-alpha.12",
4
4
  "description": "Strav AI module — unified Provider interface, BrainManager, threads, prompt caching, tools / agents / MCP. Anthropic + OpenAI providers; Gemini / DeepSeek follow.",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
7
7
  "types": "./src/index.ts",
8
8
  "exports": {
9
- ".": "./src/index.ts"
9
+ ".": "./src/index.ts",
10
+ "./mcp": "./src/mcp/index.ts"
10
11
  },
11
12
  "files": [
12
13
  "src",
@@ -19,8 +20,9 @@
19
20
  "access": "public"
20
21
  },
21
22
  "dependencies": {
22
- "@strav/kernel": "1.0.0-alpha.11",
23
23
  "@anthropic-ai/sdk": "^0.100.0",
24
+ "@modelcontextprotocol/sdk": "^1.29.0",
25
+ "@strav/kernel": "1.0.0-alpha.12",
24
26
  "openai": "^6.0.0"
25
27
  },
26
28
  "peerDependencies": {
@@ -0,0 +1,157 @@
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` is forwarded as
20
+ * `Authorization: Bearer <token>`. OAuth-flow servers need
21
+ * out-of-band token exchange — same constraint as the server-side
22
+ * path. Full OAuth handshake is a later slice.
23
+ *
24
+ * Transport:
25
+ * V1 only does Streamable HTTP — the current MCP transport. Legacy
26
+ * SSE-only endpoints aren't supported; if a server URL ends with
27
+ * `/sse` and only speaks the legacy protocol, the connection will
28
+ * fail and apps should run against a Streamable-HTTP endpoint
29
+ * instead.
30
+ */
31
+
32
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js'
33
+ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
34
+ import { BrainError } from '../brain_error.ts'
35
+ import type { MCPServer } from '../mcp_server.ts'
36
+
37
+ /** Result of a single MCP tool invocation, as returned by `tools/call`. */
38
+ export interface MCPCallToolResult {
39
+ /** Stringified content — text blocks concatenated; image / resource blocks JSON-serialized. */
40
+ content: string
41
+ /** `true` when the MCP server reports the tool execution failed. */
42
+ isError: boolean
43
+ }
44
+
45
+ /** Tool descriptor surfaced by `tools/list`. */
46
+ export interface MCPToolDescriptor {
47
+ name: string
48
+ description: string
49
+ inputSchema: Record<string, unknown>
50
+ }
51
+
52
+ export interface MCPClientOptions {
53
+ /** Override the transport used to dial the server. Tests inject a mock here. */
54
+ client?: Client
55
+ }
56
+
57
+ export class MCPClient {
58
+ readonly server: MCPServer
59
+ private readonly _client: Client
60
+ private _connected = false
61
+
62
+ constructor(server: MCPServer, options: MCPClientOptions = {}) {
63
+ this.server = server
64
+ this._client =
65
+ options.client ??
66
+ new Client(
67
+ { name: `@strav/brain:${server.name}`, version: '1.0.0' },
68
+ { capabilities: {} },
69
+ )
70
+ }
71
+
72
+ async connect(): Promise<void> {
73
+ if (this._connected) return
74
+ const transport = this._buildTransport()
75
+ try {
76
+ await this._client.connect(transport)
77
+ this._connected = true
78
+ } catch (cause) {
79
+ throw new BrainError(
80
+ `MCPClient(${this.server.name}): failed to connect to ${this.server.url}.`,
81
+ { context: { server: this.server.name, url: this.server.url }, cause },
82
+ )
83
+ }
84
+ }
85
+
86
+ async listTools(): Promise<MCPToolDescriptor[]> {
87
+ await this.connect()
88
+ let response: Awaited<ReturnType<Client['listTools']>>
89
+ try {
90
+ response = await this._client.listTools()
91
+ } catch (cause) {
92
+ throw new BrainError(
93
+ `MCPClient(${this.server.name}): tools/list failed.`,
94
+ { context: { server: this.server.name }, cause },
95
+ )
96
+ }
97
+ return response.tools.map((t) => ({
98
+ name: t.name,
99
+ description: t.description ?? '',
100
+ inputSchema: (t.inputSchema ?? { type: 'object' }) as Record<string, unknown>,
101
+ }))
102
+ }
103
+
104
+ async callTool(name: string, input: unknown): Promise<MCPCallToolResult> {
105
+ await this.connect()
106
+ let response: Awaited<ReturnType<Client['callTool']>>
107
+ try {
108
+ response = await this._client.callTool({
109
+ name,
110
+ arguments: (input ?? {}) as Record<string, unknown>,
111
+ })
112
+ } catch (cause) {
113
+ throw new BrainError(
114
+ `MCPClient(${this.server.name}): tools/call ${name} failed.`,
115
+ { context: { server: this.server.name, tool: name }, cause },
116
+ )
117
+ }
118
+ return {
119
+ content: flattenContent(response.content),
120
+ isError: Boolean(response.isError),
121
+ }
122
+ }
123
+
124
+ async close(): Promise<void> {
125
+ if (!this._connected) return
126
+ try {
127
+ await this._client.close()
128
+ } finally {
129
+ this._connected = false
130
+ }
131
+ }
132
+
133
+ private _buildTransport(): StreamableHTTPClientTransport {
134
+ const headers: Record<string, string> = {}
135
+ if (this.server.authorizationToken !== undefined) {
136
+ headers.Authorization = `Bearer ${this.server.authorizationToken}`
137
+ }
138
+ return new StreamableHTTPClientTransport(new URL(this.server.url), {
139
+ requestInit: { headers },
140
+ })
141
+ }
142
+ }
143
+
144
+ function flattenContent(
145
+ content: Awaited<ReturnType<Client['callTool']>>['content'],
146
+ ): string {
147
+ if (!Array.isArray(content)) return ''
148
+ const parts: string[] = []
149
+ for (const block of content) {
150
+ if (block.type === 'text') {
151
+ parts.push(block.text)
152
+ } else {
153
+ parts.push(JSON.stringify(block))
154
+ }
155
+ }
156
+ return parts.join('')
157
+ }
@@ -0,0 +1,16 @@
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
+ resolveMcpTools,
14
+ type ResolveMcpToolsOptions,
15
+ type ResolvedMcpTools,
16
+ } from './resolve_mcp_tools.ts'
@@ -0,0 +1,86 @@
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
+
25
+ export interface ResolvedMcpTools {
26
+ tools: Tool[]
27
+ close(): Promise<void>
28
+ }
29
+
30
+ export interface ResolveMcpToolsOptions {
31
+ /** Override the client factory — tests inject mock clients per server here. */
32
+ clientFactory?(server: MCPServer): MCPClient
33
+ }
34
+
35
+ const NAME_SEPARATOR = '__'
36
+
37
+ export async function resolveMcpTools(
38
+ servers: readonly MCPServer[],
39
+ options: ResolveMcpToolsOptions = {},
40
+ ): Promise<ResolvedMcpTools> {
41
+ const clients: MCPClient[] = []
42
+ const tools: Tool[] = []
43
+
44
+ for (const server of servers) {
45
+ if (server.tools?.enabled === false) continue
46
+ const client = options.clientFactory
47
+ ? options.clientFactory(server)
48
+ : new MCPClient(server)
49
+ clients.push(client)
50
+
51
+ const allowed = server.tools?.allowedTools
52
+ const allowedSet = allowed ? new Set(allowed) : null
53
+
54
+ const descriptors = await client.listTools()
55
+ for (const descriptor of descriptors) {
56
+ if (allowedSet && !allowedSet.has(descriptor.name)) continue
57
+ tools.push(buildTool(server.name, client, descriptor))
58
+ }
59
+ }
60
+
61
+ return {
62
+ tools,
63
+ close: async () => {
64
+ await Promise.all(clients.map((c) => c.close()))
65
+ },
66
+ }
67
+ }
68
+
69
+ function buildTool(
70
+ serverName: string,
71
+ client: MCPClient,
72
+ descriptor: { name: string; description: string; inputSchema: Record<string, unknown> },
73
+ ): Tool {
74
+ return {
75
+ name: `${serverName}${NAME_SEPARATOR}${descriptor.name}`,
76
+ description: descriptor.description,
77
+ inputSchema: descriptor.inputSchema,
78
+ async execute(input: unknown, _ctx: ToolContext): Promise<string> {
79
+ const result = await client.callTool(descriptor.name, input)
80
+ if (result.isError) {
81
+ return `MCP tool error: ${result.content}`
82
+ }
83
+ return result.content
84
+ },
85
+ }
86
+ }
@@ -22,9 +22,12 @@
22
22
  * a `function` namespace where Anthropic uses flat tool
23
23
  * definitions.
24
24
  *
25
- * - `MCPServer[]` → throws `BrainError`. OpenAI has no
26
- * server-side MCP support; the local MCP client slice
27
- * (`@strav/brain/mcp`) lands when this is needed.
25
+ * - `MCPServer[]` → resolved via the local MCP client
26
+ * (`@strav/brain/mcp`). Each server is dialed, its tools are
27
+ * discovered, and they're merged with locally-defined tools.
28
+ * The agentic loop then treats them uniformly. Tool names are
29
+ * namespaced `<server>__<tool>` to avoid collisions. Transports
30
+ * are closed in a `finally` once the loop exits.
28
31
  *
29
32
  * - `cache: true` is a no-op. OpenAI auto-caches; there's no
30
33
  * per-block cache_control to set. The framework flag is
@@ -48,6 +51,8 @@ import OpenAI from 'openai'
48
51
  import type { AgentResult } from '../agent_result.ts'
49
52
  import { BrainError } from '../brain_error.ts'
50
53
  import type { OpenAIProviderConfig } from '../brain_config.ts'
54
+ import type { MCPServer } from '../mcp_server.ts'
55
+ import { resolveMcpTools, type ResolveMcpToolsOptions } from '../mcp/resolve_mcp_tools.ts'
51
56
  import type { Provider, RunWithToolsOptions } from '../provider.ts'
52
57
  import type { Tool } from '../tool.ts'
53
58
  import { ToolExecutionError } from '../tool_execution_error.ts'
@@ -66,20 +71,32 @@ import type {
66
71
 
67
72
  const DEFAULT_OPENAI_MODEL = 'gpt-5'
68
73
 
74
+ export interface OpenAIProviderOptions {
75
+ client?: OpenAI
76
+ /**
77
+ * Internal seam — tests inject a stub MCP client factory so MCP
78
+ * tool resolution doesn't dial the network. Real apps leave it
79
+ * unset; the provider uses the default `MCPClient`.
80
+ */
81
+ mcpClientFactory?: ResolveMcpToolsOptions['clientFactory']
82
+ }
83
+
69
84
  export class OpenAIProvider implements Provider {
70
85
  readonly name: string
71
86
  private readonly client: OpenAI
72
87
  private readonly defaultModel: string
73
88
  private readonly defaultMaxTokens: number
89
+ private readonly mcpClientFactory?: ResolveMcpToolsOptions['clientFactory']
74
90
 
75
91
  constructor(
76
92
  name: string,
77
93
  config: OpenAIProviderConfig,
78
- options: { client?: OpenAI } = {},
94
+ options: OpenAIProviderOptions = {},
79
95
  ) {
80
96
  this.name = name
81
97
  this.defaultModel = config.defaultModel ?? DEFAULT_OPENAI_MODEL
82
98
  this.defaultMaxTokens = config.defaultMaxTokens ?? 4096
99
+ this.mcpClientFactory = options.mcpClientFactory
83
100
  this.client =
84
101
  options.client ??
85
102
  new OpenAI({
@@ -129,12 +146,25 @@ export class OpenAIProvider implements Provider {
129
146
  tools: readonly Tool[],
130
147
  options: RunWithToolsOptions = {},
131
148
  ): Promise<AgentResult> {
132
- if (options.mcpServers && options.mcpServers.length > 0) {
133
- throw new BrainError(
134
- 'OpenAIProvider.runWithTools: MCP servers are not supported by the OpenAI provider in V1. Use the Anthropic provider for server-side MCP, or wait for the local MCP client slice.',
135
- { context: { provider: this.name } },
136
- )
149
+ const mcpServers: readonly MCPServer[] = options.mcpServers ?? []
150
+ const resolved =
151
+ mcpServers.length > 0
152
+ ? await resolveMcpTools(mcpServers, {
153
+ ...(this.mcpClientFactory ? { clientFactory: this.mcpClientFactory } : {}),
154
+ })
155
+ : { tools: [] as Tool[], close: async () => {} }
156
+ try {
157
+ return await this._runLoop(messages, [...tools, ...resolved.tools], options)
158
+ } finally {
159
+ await resolved.close()
137
160
  }
161
+ }
162
+
163
+ private async _runLoop(
164
+ messages: readonly Message[],
165
+ tools: readonly Tool[],
166
+ options: RunWithToolsOptions,
167
+ ): Promise<AgentResult> {
138
168
  const maxIterations = options.maxIterations ?? 10
139
169
  const toolMap = new Map<string, Tool>(tools.map((t) => [t.name, t]))
140
170
  const workingMessages: Message[] = [...messages]