typeclaw 0.20.0 → 0.22.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 (55) 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/restart/index.ts +101 -0
  5. package/src/agent/tools/restart.ts +23 -52
  6. package/src/bundled-plugins/bun-hygiene/README.md +82 -0
  7. package/src/bundled-plugins/bun-hygiene/index.ts +11 -0
  8. package/src/bundled-plugins/bun-hygiene/policy.ts +318 -0
  9. package/src/bundled-plugins/github-cli-auth/gh-command.ts +98 -6
  10. package/src/bundled-plugins/github-cli-auth/graphql-auth-nudge.ts +80 -0
  11. package/src/bundled-plugins/github-cli-auth/index.ts +7 -0
  12. package/src/bundled-plugins/memory/memory-logger.ts +6 -2
  13. package/src/bundled-plugins/reviewer/skills/code-review.ts +8 -0
  14. package/src/channels/adapters/discord-bot-classify.ts +8 -2
  15. package/src/channels/adapters/discord-bot.ts +29 -2
  16. package/src/channels/adapters/github/decoy-reviewer.ts +43 -0
  17. package/src/channels/adapters/github/inbound.ts +92 -1
  18. package/src/channels/adapters/github/index.ts +12 -1
  19. package/src/channels/adapters/github/reactions.ts +138 -4
  20. package/src/channels/adapters/slack-bot-classify.ts +2 -2
  21. package/src/channels/adapters/slack-bot.ts +129 -7
  22. package/src/channels/engagement.ts +71 -31
  23. package/src/channels/manager.ts +8 -0
  24. package/src/channels/router.ts +180 -25
  25. package/src/channels/schema.ts +18 -0
  26. package/src/channels/types.ts +16 -1
  27. package/src/cli/builtins.ts +1 -0
  28. package/src/cli/dreams.ts +148 -0
  29. package/src/cli/index.ts +1 -0
  30. package/src/cli/inspect.ts +2 -1
  31. package/src/cli/ui.ts +34 -0
  32. package/src/commands/index.ts +5 -2
  33. package/src/config/config.ts +89 -0
  34. package/src/dreams/git.ts +85 -0
  35. package/src/dreams/index.ts +134 -0
  36. package/src/dreams/parse.ts +224 -0
  37. package/src/dreams/render.ts +155 -0
  38. package/src/dreams/types.ts +50 -0
  39. package/src/mcp/catalog.ts +29 -0
  40. package/src/mcp/client.ts +236 -0
  41. package/src/mcp/index.ts +25 -0
  42. package/src/mcp/manager.ts +156 -0
  43. package/src/mcp/tools.ts +190 -0
  44. package/src/permissions/builtins.ts +9 -0
  45. package/src/reload/format.ts +14 -0
  46. package/src/reload/index.ts +1 -0
  47. package/src/run/bundled-plugins.ts +7 -0
  48. package/src/run/channel-session-factory.ts +3 -0
  49. package/src/run/index.ts +38 -1
  50. package/src/server/command-runner.ts +5 -0
  51. package/src/server/index.ts +53 -0
  52. package/src/shared/protocol.ts +2 -0
  53. package/src/skills/typeclaw-channel-github/SKILL.md +86 -22
  54. package/src/tui/index.ts +70 -18
  55. package/typeclaw.schema.json +82 -0
@@ -0,0 +1,155 @@
1
+ import { styleText } from 'node:util'
2
+
3
+ import type { DreamCategory, DreamEntry, DreamEntryDetail } from './types'
4
+
5
+ export type RenderOptions = { color: boolean }
6
+
7
+ type ColorName = 'dim' | 'cyan' | 'green' | 'yellow' | 'magenta' | 'gray'
8
+
9
+ function tint(opts: RenderOptions, color: ColorName, text: string): string {
10
+ if (!opts.color) return text
11
+ return styleText(color, text)
12
+ }
13
+
14
+ const CATEGORY_LABELS: Record<DreamCategory, string> = {
15
+ fragments: 'frag',
16
+ skills: 'skill',
17
+ 'watermarks-only': 'watermarks',
18
+ snapshot: 'snapshot',
19
+ other: 'other',
20
+ }
21
+
22
+ export function renderListRow(entry: DreamEntry, opts: RenderOptions): string {
23
+ const emoji = entry.emoji ?? '·'
24
+ const sha = tint(opts, 'cyan', entry.shortSha)
25
+ const date = tint(opts, 'dim', formatShortDate(entry.committedAt))
26
+ const when = tint(opts, 'dim', `(${formatRelative(entry.committedAt)})`)
27
+ const summary = entry.summary ?? entry.subject
28
+ const badges = renderCategoryBadges(entry.categories, opts)
29
+ const head = `${emoji} ${sha} ${date} ${when} ${summary}`
30
+ return badges.length > 0 ? `${head} ${badges}` : head
31
+ }
32
+
33
+ function renderCategoryBadges(categories: DreamCategory[], opts: RenderOptions): string {
34
+ if (categories.length === 0) return ''
35
+ const meaningful = categories.filter((c) => c !== 'other')
36
+ const shown = meaningful.length > 0 ? meaningful : categories
37
+ const labels = shown.map((c) => CATEGORY_LABELS[c])
38
+ return tint(opts, 'magenta', labels.map((l) => `[${l}]`).join(' '))
39
+ }
40
+
41
+ export function renderDetail(entry: DreamEntry, opts: RenderOptions): string {
42
+ const lines: string[] = []
43
+ const emoji = entry.emoji ?? '·'
44
+ lines.push(`${emoji} ${entry.subject}`)
45
+ lines.push(
46
+ tint(
47
+ opts,
48
+ 'dim',
49
+ `${entry.shortSha} · ${formatTimestamp(entry.committedAt)} · ${formatRelative(entry.committedAt)}`,
50
+ ),
51
+ )
52
+
53
+ const detail = entry.detail
54
+ if (detail === undefined) {
55
+ lines.push('', tint(opts, 'dim', '(no detail loaded)'))
56
+ return lines.join('\n')
57
+ }
58
+
59
+ renderFragments(lines, detail, opts)
60
+ renderTopics(lines, detail, opts)
61
+ renderSkills(lines, detail, opts)
62
+
63
+ if (detail.stateChanged) lines.push('', tint(opts, 'dim', 'state: .dreaming-state.json advanced'))
64
+ for (const warning of detail.parseWarnings) lines.push(tint(opts, 'yellow', `⚠ ${warning}`))
65
+
66
+ if (isQuietDream(detail)) {
67
+ lines.push('', tint(opts, 'dim', 'No fragments promoted, no shards changed this run.'))
68
+ }
69
+ return lines.join('\n')
70
+ }
71
+
72
+ function renderFragments(lines: string[], detail: DreamEntryDetail, opts: RenderOptions): void {
73
+ if (detail.addedFragments.length === 0) return
74
+ lines.push('', section(opts, `fragments folded in (${detail.addedFragments.length})`))
75
+ for (const f of detail.addedFragments) {
76
+ const id = tint(opts, 'dim', `${f.streamDate ?? '????'}#${f.id}`)
77
+ const topic = f.topic !== null ? tint(opts, 'magenta', ` [${f.topic}]`) : ''
78
+ lines.push(`• ${id}${topic}`)
79
+ if (f.bodyPreview !== null) lines.push(` ${tint(opts, 'gray', `"${f.bodyPreview}"`)}`)
80
+ }
81
+ }
82
+
83
+ function renderTopics(lines: string[], detail: DreamEntryDetail, opts: RenderOptions): void {
84
+ if (detail.changedTopics.length === 0) return
85
+ lines.push('', section(opts, `topic shards changed (${detail.changedTopics.length})`))
86
+ for (const t of detail.changedTopics) {
87
+ const counts =
88
+ t.additions !== null && t.deletions !== null ? tint(opts, 'dim', ` (+${t.additions} −${t.deletions})`) : ''
89
+ lines.push(`${statusGlyph(t.status, opts)} ${t.slug}${counts}`)
90
+ }
91
+ }
92
+
93
+ function renderSkills(lines: string[], detail: DreamEntryDetail, opts: RenderOptions): void {
94
+ if (detail.createdSkills.length === 0) return
95
+ lines.push('', section(opts, `skills distilled (${detail.createdSkills.length})`))
96
+ for (const s of detail.createdSkills)
97
+ lines.push(`${tint(opts, 'green', '✦')} ${s.name} ${tint(opts, 'dim', s.path)}`)
98
+ }
99
+
100
+ export function toJsonShape(entry: DreamEntry): Record<string, unknown> {
101
+ const base: Record<string, unknown> = {
102
+ sha: entry.sha,
103
+ shortSha: entry.shortSha,
104
+ committedAt: entry.committedAt,
105
+ subject: entry.subject,
106
+ isDreamCommit: entry.isDreamCommit,
107
+ summary: entry.summary,
108
+ emoji: entry.emoji,
109
+ categories: entry.categories,
110
+ }
111
+ if (entry.detail !== undefined) base.detail = entry.detail
112
+ return base
113
+ }
114
+
115
+ function isQuietDream(detail: DreamEntryDetail): boolean {
116
+ return detail.addedFragments.length === 0 && detail.changedTopics.length === 0 && detail.createdSkills.length === 0
117
+ }
118
+
119
+ function section(opts: RenderOptions, label: string): string {
120
+ return tint(opts, 'dim', `── ${label} ──`)
121
+ }
122
+
123
+ function statusGlyph(status: string, opts: RenderOptions): string {
124
+ if (status === 'added') return tint(opts, 'green', '✚ added ')
125
+ if (status === 'modified') return tint(opts, 'yellow', '✎ modified')
126
+ if (status === 'deleted') return tint(opts, 'dim', '✖ deleted ')
127
+ if (status === 'renamed') return tint(opts, 'cyan', '→ renamed ')
128
+ return '? unknown '
129
+ }
130
+
131
+ function formatRelative(iso: string): string {
132
+ const ms = Date.parse(iso)
133
+ if (Number.isNaN(ms)) return iso
134
+ const diff = Date.now() - ms
135
+ if (diff < 60_000) return 'just now'
136
+ if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`
137
+ if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`
138
+ return `${Math.floor(diff / 86_400_000)}d ago`
139
+ }
140
+
141
+ function formatTimestamp(iso: string): string {
142
+ const ms = Date.parse(iso)
143
+ if (Number.isNaN(ms)) return iso
144
+ const d = new Date(ms)
145
+ const pad = (n: number): string => String(n).padStart(2, '0')
146
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
147
+ }
148
+
149
+ function formatShortDate(iso: string): string {
150
+ const ms = Date.parse(iso)
151
+ if (Number.isNaN(ms)) return iso
152
+ const d = new Date(ms)
153
+ const pad = (n: number): string => String(n).padStart(2, '0')
154
+ return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
155
+ }
@@ -0,0 +1,50 @@
1
+ // Mirrored from the bundled memory plugin's dreaming subagent rather than
2
+ // imported: this host-stage viewer must stay decoupled from runtime plugin
3
+ // internals, and only needs to RECOGNIZE the emoji set, not own it. The
4
+ // grammar test asserts this list stays in sync with the runtime's pool.
5
+ export const DREAM_EMOJI_POOL = ['💤', '🌙', '⭐', '🛌', '😴', '🧠', '💭', '🔮'] as const
6
+ export type DreamEmoji = (typeof DREAM_EMOJI_POOL)[number]
7
+
8
+ export type DreamCategory = 'fragments' | 'skills' | 'watermarks-only' | 'snapshot' | 'other'
9
+
10
+ export type DreamEntry = {
11
+ sha: string
12
+ shortSha: string
13
+ subject: string
14
+ committedAt: string
15
+ isDreamCommit: boolean
16
+ summary: string | null
17
+ emoji: DreamEmoji | null
18
+ categories: DreamCategory[]
19
+ detail?: DreamEntryDetail
20
+ }
21
+
22
+ export type DreamEntryDetail = {
23
+ addedFragments: FragmentEventSummary[]
24
+ changedTopics: TopicShardChange[]
25
+ createdSkills: SkillCreation[]
26
+ stateChanged: boolean
27
+ parseWarnings: string[]
28
+ }
29
+
30
+ export type FragmentEventSummary = {
31
+ id: string
32
+ streamDate: string | null
33
+ topic: string | null
34
+ bodyPreview: string | null
35
+ }
36
+
37
+ export type ShardChangeStatus = 'added' | 'modified' | 'deleted' | 'renamed' | 'unknown'
38
+
39
+ export type TopicShardChange = {
40
+ path: string
41
+ slug: string
42
+ status: ShardChangeStatus
43
+ additions: number | null
44
+ deletions: number | null
45
+ }
46
+
47
+ export type SkillCreation = {
48
+ name: string
49
+ path: string
50
+ }
@@ -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'
@@ -0,0 +1,156 @@
1
+ import type { McpServer } from '@/config/config'
2
+
3
+ import { connectMcpServer, type McpConnection } from './client'
4
+
5
+ const TOOL_NAMESPACE_SEPARATOR = '__'
6
+
7
+ export type McpConnectResult =
8
+ | { ok: true; name: string; connection: McpConnection; toolCount: number }
9
+ | { ok: false; name: string; error: Error }
10
+
11
+ export type McpRefreshResult = { ok: true; name: string; toolCount: number } | { ok: false; name: string; error: Error }
12
+
13
+ export type McpManager = {
14
+ connectAll(opts?: { signal?: AbortSignal }): Promise<McpConnectResult[]>
15
+ getConnection(name: string): McpConnection | undefined
16
+ listServers(): { name: string; description?: string; connected: boolean; toolCount?: number }[]
17
+ refresh(): Promise<McpRefreshResult[]>
18
+ closeAll(): Promise<void>
19
+ }
20
+
21
+ export type ConnectMcpServerFn = (
22
+ server: McpServer,
23
+ opts: { env: NodeJS.ProcessEnv; signal?: AbortSignal },
24
+ ) => Promise<McpConnection>
25
+
26
+ export function createMcpManager(
27
+ servers: McpServer[],
28
+ opts: { env: NodeJS.ProcessEnv; connect?: ConnectMcpServerFn },
29
+ ): McpManager {
30
+ const activeServers = servers.filter((server) => server.enabled)
31
+ const connect = opts.connect ?? connectMcpServer
32
+ const connections = new Map<string, McpConnection>()
33
+ const toolCounts = new Map<string, number>()
34
+
35
+ return {
36
+ async connectAll(connectOpts: { signal?: AbortSignal } = {}): Promise<McpConnectResult[]> {
37
+ const firstIndexByName = new Map<string, number>()
38
+ const results = await Promise.all(
39
+ activeServers.map((server, index): Promise<McpConnectResult> => {
40
+ // The name is the tool namespace and the connections key, so a second
41
+ // server sharing a name would silently shadow the first. Fail the
42
+ // duplicate fast instead of connecting it, keeping routing unambiguous.
43
+ const firstIndex = firstIndexByName.get(server.name)
44
+ if (firstIndex !== undefined) {
45
+ return Promise.resolve({
46
+ ok: false,
47
+ name: server.name,
48
+ error: new Error(
49
+ `mcpServers[${index}].name duplicates mcpServers[${firstIndex}].name ('${server.name}')`,
50
+ ),
51
+ })
52
+ }
53
+ firstIndexByName.set(server.name, index)
54
+ return connectOne(server, opts.env, connect, connectOpts.signal)
55
+ }),
56
+ )
57
+ for (const result of results) {
58
+ if (!result.ok) continue
59
+ connections.set(result.name, result.connection)
60
+ toolCounts.set(result.name, result.toolCount)
61
+ }
62
+ return results
63
+ },
64
+ getConnection(name: string): McpConnection | undefined {
65
+ return connections.get(name)
66
+ },
67
+ listServers(): { name: string; description?: string; connected: boolean; toolCount?: number }[] {
68
+ return activeServers.map((server) => {
69
+ const toolCount = toolCounts.get(server.name)
70
+ return {
71
+ name: server.name,
72
+ connected: connections.has(server.name),
73
+ ...(server.description === undefined ? {} : { description: server.description }),
74
+ ...(toolCount === undefined ? {} : { toolCount }),
75
+ }
76
+ })
77
+ },
78
+ async refresh(): Promise<McpRefreshResult[]> {
79
+ // One unhealthy connection must not discard healthy servers' tool-count
80
+ // updates, so each refresh is isolated and reported per-server instead of
81
+ // letting a single rejection fail the whole batch.
82
+ return Promise.all(
83
+ [...connections.entries()].map(async ([name, connection]): Promise<McpRefreshResult> => {
84
+ try {
85
+ const tools = await connection.refresh()
86
+ toolCounts.set(name, tools.length)
87
+ return { ok: true, name, toolCount: tools.length }
88
+ } catch (cause) {
89
+ return { ok: false, name, error: normalizeError(cause) }
90
+ }
91
+ }),
92
+ )
93
+ },
94
+ async closeAll(): Promise<void> {
95
+ await Promise.allSettled([...connections.values()].map((connection) => connection.close()))
96
+ connections.clear()
97
+ toolCounts.clear()
98
+ },
99
+ }
100
+ }
101
+
102
+ export function namespaceToolName(server: string, tool: string): string {
103
+ return `${server}${TOOL_NAMESPACE_SEPARATOR}${tool}`
104
+ }
105
+
106
+ export function parseNamespacedTool(namespaced: string): { server: string; tool: string } | undefined {
107
+ // Config validation reserves `__` out of server names; splitting on the first
108
+ // separator is therefore unambiguous even when MCP tool names contain it.
109
+ const separatorIndex = namespaced.indexOf(TOOL_NAMESPACE_SEPARATOR)
110
+ if (separatorIndex <= 0) return undefined
111
+
112
+ const toolStart = separatorIndex + TOOL_NAMESPACE_SEPARATOR.length
113
+ if (toolStart >= namespaced.length) return undefined
114
+
115
+ return { server: namespaced.slice(0, separatorIndex), tool: namespaced.slice(toolStart) }
116
+ }
117
+
118
+ async function connectOne(
119
+ server: McpServer,
120
+ env: NodeJS.ProcessEnv,
121
+ connect: ConnectMcpServerFn,
122
+ signal: AbortSignal | undefined,
123
+ ): Promise<McpConnectResult> {
124
+ let connection: McpConnection | undefined
125
+ try {
126
+ connection = await connect(server, { env, signal })
127
+ const tools = await connection.listTools()
128
+ return { ok: true, name: server.name, connection, toolCount: tools.length }
129
+ } catch (cause) {
130
+ const closeError = connection === undefined ? undefined : await closeAfterFailedCatalog(connection)
131
+ if (closeError !== undefined) {
132
+ return {
133
+ ok: false,
134
+ name: server.name,
135
+ error: new Error(`MCP server "${server.name}" failed to connect and then failed to close`, {
136
+ cause: { original: cause, close: closeError },
137
+ }),
138
+ }
139
+ }
140
+ return { ok: false, name: server.name, error: normalizeError(cause) }
141
+ }
142
+ }
143
+
144
+ async function closeAfterFailedCatalog(connection: McpConnection): Promise<Error | undefined> {
145
+ try {
146
+ await connection.close()
147
+ return undefined
148
+ } catch (cause) {
149
+ return normalizeError(cause)
150
+ }
151
+ }
152
+
153
+ function normalizeError(cause: unknown): Error {
154
+ if (cause instanceof Error) return cause
155
+ return new Error(String(cause))
156
+ }