typeclaw 0.21.0 → 0.23.0

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 (47) hide show
  1. package/package.json +2 -1
  2. package/src/agent/index.ts +55 -1
  3. package/src/agent/loop-guard.ts +180 -53
  4. package/src/agent/session-origin.ts +41 -2
  5. package/src/bundled-plugins/bun-hygiene/README.md +82 -0
  6. package/src/bundled-plugins/bun-hygiene/index.ts +11 -0
  7. package/src/bundled-plugins/bun-hygiene/policy.ts +318 -0
  8. package/src/bundled-plugins/github-cli-auth/gh-command.ts +98 -6
  9. package/src/bundled-plugins/github-cli-auth/graphql-auth-nudge.ts +80 -0
  10. package/src/bundled-plugins/github-cli-auth/index.ts +7 -0
  11. package/src/bundled-plugins/memory/memory-logger.ts +34 -12
  12. package/src/bundled-plugins/reviewer/skills/code-review.ts +8 -0
  13. package/src/channels/adapters/discord-bot.ts +8 -0
  14. package/src/channels/adapters/github/inbound.ts +23 -1
  15. package/src/channels/adapters/github/index.ts +9 -0
  16. package/src/channels/adapters/slack-bot.ts +112 -5
  17. package/src/channels/adapters/telegram-bot.ts +11 -0
  18. package/src/channels/manager.ts +8 -0
  19. package/src/channels/router.ts +100 -15
  20. package/src/channels/schema.ts +18 -0
  21. package/src/channels/types.ts +27 -0
  22. package/src/cli/dreams.ts +2 -1
  23. package/src/cli/inspect-controller.ts +92 -0
  24. package/src/cli/inspect.ts +21 -123
  25. package/src/cli/ui.ts +34 -0
  26. package/src/commands/index.ts +5 -2
  27. package/src/config/config.ts +89 -0
  28. package/src/inspect/index.ts +8 -26
  29. package/src/inspect/live.ts +17 -3
  30. package/src/inspect/loop.ts +23 -17
  31. package/src/mcp/catalog.ts +29 -0
  32. package/src/mcp/client.ts +236 -0
  33. package/src/mcp/index.ts +25 -0
  34. package/src/mcp/manager.ts +156 -0
  35. package/src/mcp/tools.ts +190 -0
  36. package/src/permissions/builtins.ts +9 -0
  37. package/src/reload/format.ts +14 -0
  38. package/src/reload/index.ts +1 -0
  39. package/src/run/bundled-plugins.ts +7 -0
  40. package/src/run/channel-session-factory.ts +3 -0
  41. package/src/run/index.ts +38 -1
  42. package/src/server/command-runner.ts +5 -0
  43. package/src/server/index.ts +4 -0
  44. package/src/skills/typeclaw-channel-github/SKILL.md +83 -13
  45. package/src/skills/typeclaw-config/SKILL.md +1 -1
  46. package/src/skills/typeclaw-git/SKILL.md +1 -1
  47. package/typeclaw.schema.json +82 -0
@@ -8,6 +8,7 @@ import { z } from 'zod'
8
8
  import { channelsSchema } from '@/channels/schema'
9
9
  import { commitSystemFileSync } from '@/git/system-commit'
10
10
  import { rolesConfigSchema } from '@/permissions/schema'
11
+ import { secretFieldSchema } from '@/secrets/resolve'
11
12
 
12
13
  import {
13
14
  DEFAULT_MODEL_REF,
@@ -30,6 +31,30 @@ const DEFAULT_PORT = 8973
30
31
  // of files like `mounts/.git` or `mounts/Hello`.
31
32
  const MOUNT_NAME_PATTERN = /^[a-z0-9][a-z0-9-_]*$/
32
33
 
34
+ // Shell-portable env var identifier: a leading letter or underscore followed by
35
+ // letters, digits, or underscores. MCP `env` keys are passed verbatim to a child
36
+ // process environment, so an invalid identifier (spaces, `=`, leading digit)
37
+ // would be silently dropped or corrupt the spawned server's env.
38
+ const ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/
39
+
40
+ // Upper bound for a per-server MCP request timeout: 10 minutes. Long-running
41
+ // MCP tools (large crawls, builds) can legitimately take minutes, but a ceiling
42
+ // guards against fat-finger values that would re-introduce the unbounded-hang
43
+ // failure mode the explicit timeouts exist to prevent.
44
+ const MCP_MAX_TIMEOUT_MS = 600_000
45
+
46
+ // URL schemes are case-insensitive (RFC 3986), and the WHATWG parser normalizes
47
+ // `.protocol` to lowercase. Checking the parsed protocol instead of a raw
48
+ // `startsWith` keeps `HTTPS://…` valid, which `z.string().url()` already accepts.
49
+ function isHttpProtocol(value: string): boolean {
50
+ try {
51
+ const protocol = new URL(value).protocol
52
+ return protocol === 'http:' || protocol === 'https:'
53
+ } catch {
54
+ return false
55
+ }
56
+ }
57
+
33
58
  export const mountSchema = z.object({
34
59
  name: z.string().regex(MOUNT_NAME_PATTERN, 'mount name must be lowercase alphanumeric with - or _'),
35
60
  path: z.string().min(1),
@@ -39,6 +64,66 @@ export const mountSchema = z.object({
39
64
 
40
65
  export type Mount = z.infer<typeof mountSchema>
41
66
 
67
+ // MCP servers are keyed by the same shell/disk-safe namespace as mounts because
68
+ // the name becomes the tool namespace exposed to the agent. The transport is an
69
+ // XOR on purpose: stdio servers are child processes (`command` + `args` + env),
70
+ // while Streamable HTTP servers are remote endpoints (`url`); accepting both
71
+ // would make ownership, lifetime, and credential injection ambiguous at boot.
72
+ export const mcpServerSchema = z
73
+ .object({
74
+ name: z
75
+ .string()
76
+ .regex(MOUNT_NAME_PATTERN, 'MCP server name must be lowercase alphanumeric with - or _')
77
+ .refine((name) => !name.includes('__'), {
78
+ message: "MCP server name must not contain '__' (reserved as the tool-namespace separator)",
79
+ }),
80
+ description: z.string().optional(),
81
+ // Default true so omitting the field keeps the server on; set false to keep config but skip connecting.
82
+ enabled: z.boolean().default(true),
83
+ timeoutMs: z.number().int().positive().max(MCP_MAX_TIMEOUT_MS).optional(),
84
+ command: z.string().trim().min(1).optional(),
85
+ args: z.array(z.string()).default([]),
86
+ url: z
87
+ .string()
88
+ .url()
89
+ .refine((u) => isHttpProtocol(u), {
90
+ message: 'MCP server url must use http:// or https://',
91
+ })
92
+ .optional(),
93
+ env: z
94
+ .record(z.string().regex(ENV_NAME_PATTERN, 'env var name must be a valid identifier'), secretFieldSchema)
95
+ .default({}),
96
+ })
97
+ .refine((server) => (server.command !== undefined) !== (server.url !== undefined), {
98
+ message: 'MCP server must be either stdio (command) or http (url), not both or neither',
99
+ })
100
+
101
+ export type McpServer = z.infer<typeof mcpServerSchema>
102
+
103
+ // The name becomes the `<server>__<tool>` namespace at dispatch, so duplicates
104
+ // would make tool lookup ambiguous and silently shadow one server behind
105
+ // another. Reject them with an indexed path so the error points at the
106
+ // offending entry instead of the whole array.
107
+ const mcpServersArraySchema = z
108
+ .array(mcpServerSchema)
109
+ .default([])
110
+ .superRefine((entries, ctx) => {
111
+ const seen = new Map<string, number>()
112
+ for (let i = 0; i < entries.length; i++) {
113
+ const name = entries[i]!.name
114
+ const prev = seen.get(name)
115
+ if (prev !== undefined) {
116
+ ctx.addIssue({
117
+ code: 'custom',
118
+ path: [i, 'name'],
119
+ message: `mcpServers[${i}].name duplicates mcpServers[${prev}].name ('${name}')`,
120
+ })
121
+ } else {
122
+ seen.set(name, i)
123
+ }
124
+ }
125
+ })
126
+
42
127
  const portNumber = z.number().int().min(1).max(65535)
43
128
 
44
129
  // `allow` is the discriminator between "forward everything" ('*') and a fixed
@@ -391,6 +476,7 @@ export const configSchema = z
391
476
  // host paths exposed) without failing the whole config load. `typeclaw
392
477
  // init` omits this field so users don't see noise for the empty case.
393
478
  mounts: z.array(mountSchema).default([]),
479
+ mcpServers: mcpServersArraySchema,
394
480
  plugins: z.array(z.string().min(1)).default([]),
395
481
  // Additional names the agent answers to in channel engagement, on top
396
482
  // of `basename(agentDir)` which is always implicit. Each entry is a
@@ -538,6 +624,7 @@ export const FIELD_EFFECTS: Record<string, FieldEffect> = {
538
624
  models: 'applied',
539
625
  port: 'restart-required',
540
626
  mounts: 'restart-required',
627
+ mcpServers: 'restart-required',
541
628
  plugins: 'restart-required',
542
629
  alias: 'applied',
543
630
  channels: 'applied',
@@ -638,6 +725,8 @@ export function extractPluginConfigs(raw: unknown): Record<string, unknown> {
638
725
  'git',
639
726
  'roles',
640
727
  'permissions',
728
+ 'tunnels',
729
+ 'mcpServers',
641
730
  ])
642
731
  const result: Record<string, unknown> = {}
643
732
  for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
@@ -30,10 +30,9 @@ export type RunInspectOptions = {
30
30
  stdout: (line: string) => void
31
31
  stderr: (line: string) => void
32
32
  liveSource?: LiveSourceFactory
33
+ // Aborting this signal stops the live tail and returns escToPicker=true; the
34
+ // caller's loop inspects its own scope intent to tell back from exit.
33
35
  signal?: AbortSignal
34
- // Aborting escSignal (and only escSignal) returns escToPicker=true so a
35
- // caller-side loop can re-open the picker; signal still means process exit.
36
- escSignal?: AbortSignal
37
36
  liveHint?: string
38
37
  }
39
38
 
@@ -81,7 +80,6 @@ export async function runInspect(opts: RunInspectOptions): Promise<RunInspectRes
81
80
  stderr: opts.stderr,
82
81
  ...(opts.liveSource !== undefined ? { liveSource: opts.liveSource } : {}),
83
82
  ...(opts.signal !== undefined ? { signal: opts.signal } : {}),
84
- ...(opts.escSignal !== undefined ? { escSignal: opts.escSignal } : {}),
85
83
  ...(opts.liveHint !== undefined ? { liveHint: opts.liveHint } : {}),
86
84
  })
87
85
  if (streamResult.escToPicker) return { ok: true, exitCode: 0, escToPicker: true }
@@ -147,7 +145,6 @@ async function streamSession(opts: {
147
145
  stderr: (line: string) => void
148
146
  liveSource?: LiveSourceFactory
149
147
  signal?: AbortSignal
150
- escSignal?: AbortSignal
151
148
  liveHint?: string
152
149
  }): Promise<{ escToPicker: boolean }> {
153
150
  if (!opts.json) writeHeader(opts.summary, opts.color, opts.stdout)
@@ -161,26 +158,25 @@ async function streamSession(opts: {
161
158
  }
162
159
  }
163
160
 
164
- const escAborted = (): boolean => opts.escSignal?.aborted === true
161
+ const aborted = (): boolean => opts.signal?.aborted === true
165
162
 
166
163
  for await (const event of replayJsonl(opts.summary.sessionFile, { onWarn: opts.stderr })) {
167
- if (escAborted()) return { escToPicker: true }
164
+ if (aborted()) return { escToPicker: true }
168
165
  emit(event)
169
166
  }
170
167
 
171
168
  if (opts.liveSource === undefined) {
172
169
  if (!opts.json) opts.stdout('─── end of transcript ───')
173
- return { escToPicker: escAborted() }
170
+ return { escToPicker: aborted() }
174
171
  }
175
172
 
176
- if (escAborted()) return { escToPicker: true }
173
+ if (aborted()) return { escToPicker: true }
177
174
 
178
- const combinedSignal = combineSignals(opts.signal, opts.escSignal)
179
175
  let sessionLive = false
180
176
  const liveIter = opts.liveSource({
181
177
  sessionId: opts.summary.sessionId,
182
178
  ...(opts.sinceMs !== undefined ? { sinceMs: opts.sinceMs } : {}),
183
- ...(combinedSignal !== undefined ? { signal: combinedSignal } : {}),
179
+ ...(opts.signal !== undefined ? { signal: opts.signal } : {}),
184
180
  onSubscribed: (live) => {
185
181
  sessionLive = live
186
182
  },
@@ -204,21 +200,7 @@ async function streamSession(opts: {
204
200
  opts.stderr(`live tail ended: ${err instanceof Error ? err.message : String(err)}`)
205
201
  }
206
202
  if (!opts.json) opts.stdout('─── end of transcript ───')
207
- return { escToPicker: escAborted() && opts.signal?.aborted !== true }
208
- }
209
-
210
- function combineSignals(a: AbortSignal | undefined, b: AbortSignal | undefined): AbortSignal | undefined {
211
- if (a === undefined) return b
212
- if (b === undefined) return a
213
- if (a.aborted) return a
214
- if (b.aborted) return b
215
- const ctrl = new AbortController()
216
- const onAbort = (): void => {
217
- ctrl.abort()
218
- }
219
- a.addEventListener('abort', onAbort, { once: true })
220
- b.addEventListener('abort', onAbort, { once: true })
221
- return ctrl.signal
203
+ return { escToPicker: aborted() }
222
204
  }
223
205
 
224
206
  function divider(color: boolean, text: string): string {
@@ -10,8 +10,11 @@ export type StreamLiveOptions = {
10
10
  WebSocketImpl?: typeof WebSocket
11
11
  onSubscribed?: (live: boolean) => void
12
12
  onError?: (message: string) => void
13
+ connectTimeoutMs?: number
13
14
  }
14
15
 
16
+ const DEFAULT_CONNECT_TIMEOUT_MS = 5_000
17
+
15
18
  export async function* streamLive(opts: StreamLiveOptions): AsyncGenerator<InspectEvent> {
16
19
  const WS = opts.WebSocketImpl ?? WebSocket
17
20
  const ws = new WS(opts.url)
@@ -63,9 +66,11 @@ export async function* streamLive(opts: StreamLiveOptions): AsyncGenerator<Inspe
63
66
  }
64
67
  })
65
68
 
66
- // Settle on open OR on any terminal condition (error/close/abort). Resolving
67
- // false here is what unblocks the connect gate when esc aborts mid-connect
68
- // otherwise `await onOpen` would hang forever and freeze the inspect CLI.
69
+ // Settle on open OR on any terminal condition (error/close/abort/timeout).
70
+ // Resolving false on abort/close/timeout is what unblocks the connect gate —
71
+ // otherwise `await onOpen` would hang forever and freeze the inspect CLI. The
72
+ // timeout bounds Bun/websocket states that neither open nor error promptly.
73
+ let connectTimer: ReturnType<typeof setTimeout> | null = null
69
74
  const onOpen = new Promise<boolean>((resolve, reject) => {
70
75
  ws.addEventListener('open', () => resolve(true), { once: true })
71
76
  ws.addEventListener('error', () => reject(new Error('websocket connection failed')), { once: true })
@@ -74,6 +79,8 @@ export async function* streamLive(opts: StreamLiveOptions): AsyncGenerator<Inspe
74
79
  if (opts.signal.aborted) resolve(false)
75
80
  else opts.signal.addEventListener('abort', () => resolve(false), { once: true })
76
81
  }
82
+ const timeoutMs = opts.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS
83
+ connectTimer = setTimeout(() => reject(new Error('websocket connect timed out')), timeoutMs)
77
84
  })
78
85
  ws.addEventListener('close', () => {
79
86
  closed = true
@@ -109,7 +116,14 @@ export async function* streamLive(opts: StreamLiveOptions): AsyncGenerator<Inspe
109
116
  opened = await onOpen
110
117
  } catch (err) {
111
118
  closed = true
119
+ try {
120
+ ws.close()
121
+ } catch {
122
+ /* ignore */
123
+ }
112
124
  throw err
125
+ } finally {
126
+ if (connectTimer !== null) clearTimeout(connectTimer)
113
127
  }
114
128
  if (!opened || closed || opts.signal?.aborted === true) return
115
129
 
@@ -1,20 +1,21 @@
1
1
  import { runInspect, type RunInspectOptions, type RunInspectResult } from './index'
2
2
 
3
- export type RunInspectLoopOptions = Omit<RunInspectOptions, 'escSignal'> & {
4
- newEscSignal: () => AbortSignal
5
- // Runs after every runInspect attempt settles. The caller disarms the raw-mode
6
- // ESC listener here so the live tail releases stdin before clack re-opens the
7
- // picker: an ESC-aborted tail leaves the listener armed (raw mode on, 'data'
8
- // handler attached), and handing clack that flowing stream freezes the picker
9
- // on SSH/Bun pseudo-TTYs.
10
- afterEscStream?: () => void
3
+ export type TailController = {
4
+ signal: AbortSignal
5
+ intent: () => 'back' | 'exit' | null
6
+ dispose: () => void
7
+ }
8
+
9
+ export type RunInspectLoopOptions = Omit<RunInspectOptions, 'signal'> & {
10
+ // Builds a fresh interaction scope for ONE live-tail attempt: a new
11
+ // AbortController plus a temporary raw-mode listener. The loop disposes it
12
+ // before the picker re-opens so clack always owns a clean, non-raw stdin —
13
+ // this is what replaces the old pause/resume-same-controller model.
14
+ createTailScope: () => TailController
11
15
  }
12
16
 
13
17
  export async function runInspectLoop(opts: RunInspectLoopOptions): Promise<RunInspectResult> {
14
18
  let sessionArg = opts.sessionIdOrPrefix
15
- // Remember the last session the user picked from the interactive picker so
16
- // an ESC-back-to-picker re-opens with that row pre-selected. The picker
17
- // receives this through the `initialSessionId` hint on its second arg.
18
19
  let lastPickedId: string | undefined
19
20
  const wrappedSelectSession: typeof opts.selectSession = async (sessions, selectOpts) => {
20
21
  const hint = selectOpts?.initialSessionId ?? lastPickedId
@@ -24,18 +25,23 @@ export async function runInspectLoop(opts: RunInspectLoopOptions): Promise<RunIn
24
25
  }
25
26
 
26
27
  while (true) {
27
- const escSignal = opts.newEscSignal()
28
- const callOpts: RunInspectOptions = { ...opts, escSignal, selectSession: wrappedSelectSession }
29
- if (sessionArg !== undefined) callOpts.sessionIdOrPrefix = sessionArg
30
- else delete (callOpts as { sessionIdOrPrefix?: string }).sessionIdOrPrefix
31
-
28
+ const scope = opts.createTailScope()
32
29
  let result: RunInspectResult
33
30
  try {
31
+ const callOpts: RunInspectOptions = {
32
+ ...opts,
33
+ selectSession: wrappedSelectSession,
34
+ signal: scope.signal,
35
+ }
36
+ if (sessionArg !== undefined) callOpts.sessionIdOrPrefix = sessionArg
37
+ else delete (callOpts as { sessionIdOrPrefix?: string }).sessionIdOrPrefix
34
38
  result = await runInspect(callOpts)
35
39
  } finally {
36
- opts.afterEscStream?.()
40
+ scope.dispose()
37
41
  }
42
+
38
43
  if (!result.ok) return result
44
+ if (scope.intent() === 'exit') return result
39
45
  if (result.escToPicker !== true) return result
40
46
  sessionArg = undefined
41
47
  }
@@ -0,0 +1,29 @@
1
+ export type McpCatalogServer = {
2
+ name: string
3
+ description?: string
4
+ connected: boolean
5
+ toolCount?: number
6
+ }
7
+
8
+ export function renderMcpCatalog(servers: McpCatalogServer[]): string {
9
+ const connected = servers.filter((server) => server.connected)
10
+ if (connected.length === 0) return ''
11
+
12
+ const lines = connected.map((server) => {
13
+ const toolCount = server.toolCount ?? 0
14
+ const description = server.description?.trim() ? server.description.trim() : 'no description'
15
+ return `- ${server.name} (${toolCount} tools): ${description}`
16
+ })
17
+
18
+ // WHY: this catalog is boot-stable for prompt-cache locality; MCP
19
+ // tools/list_changed notifications are intentionally not reflected here until
20
+ // the manager refresh path is invoked or the session restarts.
21
+ return [
22
+ '## MCP servers',
23
+ '',
24
+ 'The following MCP servers are connected. Each exposes tools you can discover and call:',
25
+ ...lines,
26
+ '',
27
+ "Use `mcp_list_tools(server)` to see a server's tools, `mcp_describe(server, tool)` for a tool's input schema, and `mcp_call(server, tool, args)` to invoke it.",
28
+ ].join('\n')
29
+ }
@@ -0,0 +1,236 @@
1
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js'
2
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
3
+ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
4
+ import type { RequestOptions } from '@modelcontextprotocol/sdk/shared/protocol.js'
5
+ import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'
6
+ import { CallToolResultSchema, type CallToolResult, type ListToolsRequest } from '@modelcontextprotocol/sdk/types.js'
7
+
8
+ import type { McpServer } from '@/config/config'
9
+ import { resolveSecret } from '@/secrets/resolve'
10
+
11
+ export type McpToolInfo = {
12
+ name: string
13
+ description: string
14
+ inputSchema: unknown
15
+ }
16
+
17
+ // The SDK defaults each request to 60s; typeclaw boot should fail fast enough
18
+ // that one dead MCP server does not make the agent feel hung at startup.
19
+ export const DEFAULT_MCP_REQUEST_TIMEOUT_MS = 30_000
20
+ export const DEFAULT_MCP_CONNECT_TIMEOUT_MS = 15_000
21
+
22
+ export type McpConnection = {
23
+ name: string
24
+ listTools(): Promise<McpToolInfo[]>
25
+ refresh(): Promise<McpToolInfo[]>
26
+ callTool(toolName: string, args?: Record<string, unknown>): Promise<CallToolResult>
27
+ close(): Promise<void>
28
+ }
29
+
30
+ export type McpSdkClient = {
31
+ listTools(
32
+ params?: ListToolsRequest['params'],
33
+ options?: RequestOptions,
34
+ ): Promise<{
35
+ tools: { name: string; description?: string; inputSchema: unknown }[]
36
+ nextCursor?: string
37
+ }>
38
+ callTool(
39
+ params: { name: string; arguments?: Record<string, unknown> },
40
+ resultSchema?: typeof CallToolResultSchema,
41
+ options?: RequestOptions,
42
+ ): Promise<CallToolResult>
43
+ close(): Promise<void>
44
+ }
45
+
46
+ type McpConnectClient = McpSdkClient & {
47
+ connect(transport: Transport, options?: RequestOptions): Promise<void>
48
+ }
49
+
50
+ export async function connectMcpServer(
51
+ server: McpServer,
52
+ opts: {
53
+ env: NodeJS.ProcessEnv
54
+ signal?: AbortSignal
55
+ connectTimeoutMs?: number
56
+ client?: McpConnectClient
57
+ transport?: Transport
58
+ },
59
+ ): Promise<McpConnection> {
60
+ const requestTimeout = server.timeoutMs ?? DEFAULT_MCP_REQUEST_TIMEOUT_MS
61
+ const connectTimeout = opts.connectTimeoutMs ?? DEFAULT_MCP_CONNECT_TIMEOUT_MS
62
+ const client = opts.client ?? new Client({ name: 'typeclaw', version: '0.17.0' }, { capabilities: {} })
63
+ const transport = opts.transport ?? createTransport(server, opts.env)
64
+
65
+ try {
66
+ await withConnectDeadline(connectTimeout, opts.signal, (signal) =>
67
+ client.connect(transport, { signal, timeout: requestTimeout }),
68
+ )
69
+ } catch (cause) {
70
+ try {
71
+ await client.close()
72
+ } catch (closeCause) {
73
+ attachCloseCause(cause, closeCause)
74
+ }
75
+ throw cause
76
+ }
77
+
78
+ return createMcpConnection(
79
+ server.name,
80
+ {
81
+ listTools: (params, options) => client.listTools(params, options),
82
+ async callTool(params, _resultSchema, options) {
83
+ const result = await client.callTool(params, CallToolResultSchema, options)
84
+ return CallToolResultSchema.parse(result)
85
+ },
86
+ close: () => client.close(),
87
+ },
88
+ { timeoutMs: requestTimeout },
89
+ )
90
+ }
91
+
92
+ export function createMcpConnection(
93
+ name: string,
94
+ client: McpSdkClient,
95
+ opts: { timeoutMs?: number; signal?: AbortSignal } = {},
96
+ ): McpConnection {
97
+ let cachedTools: McpToolInfo[] | undefined
98
+ const timeout = opts.timeoutMs ?? DEFAULT_MCP_REQUEST_TIMEOUT_MS
99
+
100
+ async function fetchTools(): Promise<McpToolInfo[]> {
101
+ const tools: McpToolInfo[] = []
102
+ let cursor: string | undefined
103
+ do {
104
+ const result = await client.listTools(cursor === undefined ? undefined : { cursor }, {
105
+ timeout,
106
+ signal: opts.signal,
107
+ })
108
+ tools.push(
109
+ ...result.tools.map((tool) => ({
110
+ name: tool.name,
111
+ description: tool.description ?? '',
112
+ inputSchema: tool.inputSchema,
113
+ })),
114
+ )
115
+ cursor = result.nextCursor
116
+ } while (cursor !== undefined)
117
+
118
+ cachedTools = tools
119
+ return cachedTools
120
+ }
121
+
122
+ return {
123
+ name,
124
+ async listTools(): Promise<McpToolInfo[]> {
125
+ if (cachedTools !== undefined) return cachedTools
126
+ return fetchTools()
127
+ },
128
+ refresh(): Promise<McpToolInfo[]> {
129
+ cachedTools = undefined
130
+ return fetchTools()
131
+ },
132
+ callTool(toolName: string, args?: Record<string, unknown>): Promise<CallToolResult> {
133
+ return client.callTool({ name: toolName, arguments: args }, CallToolResultSchema, {
134
+ timeout,
135
+ signal: opts.signal,
136
+ })
137
+ },
138
+ close(): Promise<void> {
139
+ return client.close()
140
+ },
141
+ }
142
+ }
143
+
144
+ function createTransport(server: McpServer, env: NodeJS.ProcessEnv): Transport {
145
+ return server.command
146
+ ? new StdioClientTransport({ command: server.command, args: server.args, env: resolveServerEnv(server, env) })
147
+ : new StreamableHTTPClientTransport(new URL(requiredUrl(server)))
148
+ }
149
+
150
+ async function withConnectDeadline<T>(
151
+ timeoutMs: number,
152
+ parentSignal: AbortSignal | undefined,
153
+ fn: (signal: AbortSignal) => Promise<T>,
154
+ ): Promise<T> {
155
+ const deadline = new AbortController()
156
+ const timer = setTimeout(() => deadline.abort(new Error('MCP connect timeout')), timeoutMs)
157
+ const merged = mergeSignals(parentSignal, deadline.signal)
158
+ const operation = fn(merged.signal)
159
+ let removeAbortListener = (): void => {}
160
+ const abort = new Promise<never>((_resolve, reject) => {
161
+ const onAbort = (): void => reject(merged.signal.reason)
162
+ removeAbortListener = (): void => merged.signal.removeEventListener('abort', onAbort)
163
+ if (merged.signal.aborted) onAbort()
164
+ else merged.signal.addEventListener('abort', onAbort, { once: true })
165
+ })
166
+ try {
167
+ return await Promise.race([operation, abort])
168
+ } finally {
169
+ clearTimeout(timer)
170
+ removeAbortListener()
171
+ if (merged.signal.aborted) void operation.catch(() => undefined)
172
+ merged.dispose()
173
+ }
174
+ }
175
+
176
+ function mergeSignals(...signals: (AbortSignal | undefined)[]): { signal: AbortSignal; dispose(): void } {
177
+ const activeSignals = signals.filter((signal): signal is AbortSignal => signal !== undefined)
178
+ if (activeSignals.length === 0) return { signal: new AbortController().signal, dispose() {} }
179
+ if (activeSignals.length === 1) return { signal: activeSignals[0]!, dispose() {} }
180
+
181
+ const controller = new AbortController()
182
+ const listeners: (() => void)[] = []
183
+ for (const signal of activeSignals) {
184
+ const abort = (): void => controller.abort(signal.reason)
185
+ if (signal.aborted) {
186
+ abort()
187
+ break
188
+ }
189
+ signal.addEventListener('abort', abort, { once: true })
190
+ listeners.push(() => signal.removeEventListener('abort', abort))
191
+ }
192
+
193
+ return {
194
+ signal: controller.signal,
195
+ dispose() {
196
+ for (const remove of listeners) remove()
197
+ },
198
+ }
199
+ }
200
+
201
+ function attachCloseCause(cause: unknown, closeCause: unknown): void {
202
+ if (!(cause instanceof Error)) return
203
+ const error = cause as Error & { cause?: unknown }
204
+ error.cause = { original: error.cause, close: closeCause }
205
+ }
206
+
207
+ // A stdio MCP server is a child process the agent spawns, so it must NOT
208
+ // inherit the full parent environment: that env holds unrelated credentials
209
+ // (FIREWORKS_API_KEY, GH_TOKEN, channel tokens) and inheriting them leaks every
210
+ // secret to every server. We start from a minimal allowlist needed to spawn and
211
+ // run a process (PATH/HOME to launch npx/bunx, locale + temp for correctness),
212
+ // then overlay only the secrets the server explicitly declares. This mirrors
213
+ // the bwrap sandbox's `--clearenv` + DEFAULT_SANDBOX_ENV leak guard.
214
+ const BASE_ENV_ALLOWLIST = ['PATH', 'HOME', 'LANG', 'LC_ALL', 'TMPDIR', 'TZ'] as const
215
+
216
+ export function resolveServerEnv(server: Pick<McpServer, 'env'>, env: NodeJS.ProcessEnv): Record<string, string> {
217
+ const childEnv: Record<string, string> = {}
218
+ for (const key of BASE_ENV_ALLOWLIST) {
219
+ const value = env[key]
220
+ if (value !== undefined) childEnv[key] = value
221
+ }
222
+
223
+ for (const [key, secret] of Object.entries(server.env)) {
224
+ const explicitKeyValue = env[key]
225
+ const resolved =
226
+ explicitKeyValue !== undefined && explicitKeyValue !== '' ? explicitKeyValue : resolveSecret(secret, key, env)
227
+ if (resolved !== undefined) childEnv[key] = resolved
228
+ }
229
+
230
+ return childEnv
231
+ }
232
+
233
+ function requiredUrl(server: McpServer): string {
234
+ if (server.url !== undefined) return server.url
235
+ throw new Error(`MCP server "${server.name}" is missing url`)
236
+ }
@@ -0,0 +1,25 @@
1
+ export {
2
+ connectMcpServer,
3
+ createMcpConnection,
4
+ resolveServerEnv,
5
+ type McpConnection,
6
+ type McpSdkClient,
7
+ type McpToolInfo,
8
+ } from './client'
9
+ export {
10
+ createMcpManager,
11
+ namespaceToolName,
12
+ parseNamespacedTool,
13
+ type ConnectMcpServerFn,
14
+ type McpConnectResult,
15
+ type McpManager,
16
+ } from './manager'
17
+ export { renderMcpCatalog, type McpCatalogServer } from './catalog'
18
+ export {
19
+ createMcpDispatcherTools,
20
+ MCP_DISPATCHER_TOOL_NAMES,
21
+ type McpCallArgs,
22
+ type McpDescribeArgs,
23
+ type McpDispatcherTool,
24
+ type McpListToolsArgs,
25
+ } from './tools'