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.
- package/package.json +2 -1
- package/src/agent/index.ts +55 -1
- package/src/agent/loop-guard.ts +180 -53
- package/src/agent/session-origin.ts +41 -2
- package/src/bundled-plugins/bun-hygiene/README.md +82 -0
- package/src/bundled-plugins/bun-hygiene/index.ts +11 -0
- package/src/bundled-plugins/bun-hygiene/policy.ts +318 -0
- package/src/bundled-plugins/github-cli-auth/gh-command.ts +98 -6
- package/src/bundled-plugins/github-cli-auth/graphql-auth-nudge.ts +80 -0
- package/src/bundled-plugins/github-cli-auth/index.ts +7 -0
- package/src/bundled-plugins/memory/memory-logger.ts +34 -12
- package/src/bundled-plugins/reviewer/skills/code-review.ts +8 -0
- package/src/channels/adapters/discord-bot.ts +8 -0
- package/src/channels/adapters/github/inbound.ts +23 -1
- package/src/channels/adapters/github/index.ts +9 -0
- package/src/channels/adapters/slack-bot.ts +112 -5
- package/src/channels/adapters/telegram-bot.ts +11 -0
- package/src/channels/manager.ts +8 -0
- package/src/channels/router.ts +100 -15
- package/src/channels/schema.ts +18 -0
- package/src/channels/types.ts +27 -0
- package/src/cli/dreams.ts +2 -1
- package/src/cli/inspect-controller.ts +92 -0
- package/src/cli/inspect.ts +21 -123
- package/src/cli/ui.ts +34 -0
- package/src/commands/index.ts +5 -2
- package/src/config/config.ts +89 -0
- package/src/inspect/index.ts +8 -26
- package/src/inspect/live.ts +17 -3
- package/src/inspect/loop.ts +23 -17
- package/src/mcp/catalog.ts +29 -0
- package/src/mcp/client.ts +236 -0
- package/src/mcp/index.ts +25 -0
- package/src/mcp/manager.ts +156 -0
- package/src/mcp/tools.ts +190 -0
- package/src/permissions/builtins.ts +9 -0
- package/src/reload/format.ts +14 -0
- package/src/reload/index.ts +1 -0
- package/src/run/bundled-plugins.ts +7 -0
- package/src/run/channel-session-factory.ts +3 -0
- package/src/run/index.ts +38 -1
- package/src/server/command-runner.ts +5 -0
- package/src/server/index.ts +4 -0
- package/src/skills/typeclaw-channel-github/SKILL.md +83 -13
- package/src/skills/typeclaw-config/SKILL.md +1 -1
- package/src/skills/typeclaw-git/SKILL.md +1 -1
- package/typeclaw.schema.json +82 -0
package/src/config/config.ts
CHANGED
|
@@ -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>)) {
|
package/src/inspect/index.ts
CHANGED
|
@@ -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
|
|
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 (
|
|
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:
|
|
170
|
+
return { escToPicker: aborted() }
|
|
174
171
|
}
|
|
175
172
|
|
|
176
|
-
if (
|
|
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
|
-
...(
|
|
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:
|
|
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 {
|
package/src/inspect/live.ts
CHANGED
|
@@ -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).
|
|
67
|
-
// false
|
|
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
|
|
package/src/inspect/loop.ts
CHANGED
|
@@ -1,20 +1,21 @@
|
|
|
1
1
|
import { runInspect, type RunInspectOptions, type RunInspectResult } from './index'
|
|
2
2
|
|
|
3
|
-
export type
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|
package/src/mcp/index.ts
ADDED
|
@@ -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'
|