typeclaw 0.21.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.
- package/package.json +2 -1
- package/src/agent/index.ts +55 -1
- package/src/agent/loop-guard.ts +180 -53
- 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 +6 -2
- package/src/bundled-plugins/reviewer/skills/code-review.ts +8 -0
- package/src/channels/adapters/discord-bot.ts +2 -0
- package/src/channels/adapters/github/inbound.ts +23 -1
- package/src/channels/adapters/github/index.ts +1 -0
- package/src/channels/adapters/slack-bot.ts +104 -5
- package/src/channels/manager.ts +8 -0
- package/src/channels/router.ts +68 -15
- package/src/channels/schema.ts +18 -0
- package/src/cli/dreams.ts +2 -1
- package/src/cli/inspect.ts +2 -1
- package/src/cli/ui.ts +34 -0
- package/src/commands/index.ts +5 -2
- package/src/config/config.ts +89 -0
- 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/typeclaw.schema.json +82 -0
|
@@ -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'
|
|
@@ -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
|
+
}
|
package/src/mcp/tools.ts
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
|
|
4
|
+
import { defineTool } from '@/plugin/define'
|
|
5
|
+
import type { ContentPart, Tool, ToolResult } from '@/plugin/types'
|
|
6
|
+
|
|
7
|
+
import type { McpConnection, McpToolInfo } from './client'
|
|
8
|
+
import type { McpManager } from './manager'
|
|
9
|
+
import { namespaceToolName, parseNamespacedTool } from './manager'
|
|
10
|
+
|
|
11
|
+
export const MCP_DISPATCHER_TOOL_NAMES = ['mcp_list_tools', 'mcp_describe', 'mcp_call'] as const
|
|
12
|
+
|
|
13
|
+
export type McpListToolsArgs = { server: string }
|
|
14
|
+
export type McpDescribeArgs = { server: string; tool: string }
|
|
15
|
+
export type McpCallArgs = { server: string; tool: string; args?: Record<string, unknown> }
|
|
16
|
+
export type McpDispatcherTool = Tool<McpListToolsArgs> | Tool<McpDescribeArgs> | Tool<McpCallArgs>
|
|
17
|
+
export type McpDispatcherTools = [Tool<McpListToolsArgs>, Tool<McpDescribeArgs>, Tool<McpCallArgs>]
|
|
18
|
+
|
|
19
|
+
export function createMcpDispatcherTools(manager: McpManager): McpDispatcherTools {
|
|
20
|
+
return [createListToolsTool(manager), createDescribeTool(manager), createCallTool(manager)]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function createListToolsTool(manager: McpManager): Tool<McpListToolsArgs> {
|
|
24
|
+
return defineTool<McpListToolsArgs>({
|
|
25
|
+
description: 'List the tools exposed by one connected MCP server. Returns namespaced tool ids and descriptions.',
|
|
26
|
+
parameters: z.object({
|
|
27
|
+
server: z.string().describe('The MCP server name from the system prompt catalog.'),
|
|
28
|
+
}),
|
|
29
|
+
async execute(args) {
|
|
30
|
+
const connection = manager.getConnection(args.server)
|
|
31
|
+
if (connection === undefined) return textResult(unknownServerMessage(manager, args.server))
|
|
32
|
+
|
|
33
|
+
const tools = await safeListTools(connection)
|
|
34
|
+
if (tools.length === 0) return textResult(`MCP server ${JSON.stringify(args.server)} exposes no tools.`)
|
|
35
|
+
|
|
36
|
+
const lines = tools.map((tool) => {
|
|
37
|
+
const description = tool.description.trim() === '' ? 'no description' : tool.description.trim()
|
|
38
|
+
return `- ${namespaceToolName(args.server, tool.name)} — ${description}`
|
|
39
|
+
})
|
|
40
|
+
return textResult(`Tools for MCP server ${JSON.stringify(args.server)}:\n${lines.join('\n')}`)
|
|
41
|
+
},
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function createDescribeTool(manager: McpManager): Tool<McpDescribeArgs> {
|
|
46
|
+
return defineTool<McpDescribeArgs>({
|
|
47
|
+
description: 'Describe one MCP tool. Returns its description and full input JSON Schema.',
|
|
48
|
+
parameters: z.object({
|
|
49
|
+
server: z.string().describe('The MCP server name from the system prompt catalog.'),
|
|
50
|
+
tool: z.string().describe('The bare tool name or namespaced server__tool id.'),
|
|
51
|
+
}),
|
|
52
|
+
async execute(args) {
|
|
53
|
+
const resolved = resolveToolArgs(args.server, args.tool)
|
|
54
|
+
const connection = manager.getConnection(resolved.server)
|
|
55
|
+
if (connection === undefined) return textResult(unknownServerMessage(manager, resolved.server))
|
|
56
|
+
|
|
57
|
+
const tools = await safeListTools(connection)
|
|
58
|
+
const tool = tools.find((item) => item.name === resolved.tool)
|
|
59
|
+
if (tool === undefined) {
|
|
60
|
+
const available = tools.map((item) => namespaceToolName(resolved.server, item.name)).join(', ')
|
|
61
|
+
return textResult(
|
|
62
|
+
`Unknown MCP tool ${JSON.stringify(resolved.tool)} on server ${JSON.stringify(resolved.server)}. Available tools: ${available || 'none'}.`,
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const description = tool.description.trim() === '' ? 'no description' : tool.description.trim()
|
|
67
|
+
return textResult(
|
|
68
|
+
[
|
|
69
|
+
`MCP tool ${namespaceToolName(resolved.server, tool.name)}`,
|
|
70
|
+
`Description: ${description}`,
|
|
71
|
+
'',
|
|
72
|
+
'Input schema:',
|
|
73
|
+
'```json',
|
|
74
|
+
JSON.stringify(tool.inputSchema, null, 2),
|
|
75
|
+
'```',
|
|
76
|
+
].join('\n'),
|
|
77
|
+
)
|
|
78
|
+
},
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function createCallTool(manager: McpManager): Tool<McpCallArgs> {
|
|
83
|
+
return defineTool<McpCallArgs>({
|
|
84
|
+
description: 'Call an MCP tool on a connected server. Use mcp_describe first to learn the input schema.',
|
|
85
|
+
parameters: z.object({
|
|
86
|
+
server: z.string().describe('The MCP server name from the system prompt catalog.'),
|
|
87
|
+
tool: z.string().describe('The bare tool name or namespaced server__tool id.'),
|
|
88
|
+
args: z.record(z.string(), z.unknown()).optional().describe('Arguments matching the tool input schema.'),
|
|
89
|
+
}),
|
|
90
|
+
async execute(args) {
|
|
91
|
+
const resolved = resolveToolArgs(args.server, args.tool)
|
|
92
|
+
const connection = manager.getConnection(resolved.server)
|
|
93
|
+
if (connection === undefined) return textResult(unknownServerMessage(manager, resolved.server))
|
|
94
|
+
|
|
95
|
+
const result = await safeCallTool(connection, resolved.tool, args.args ?? {})
|
|
96
|
+
return mapCallToolResult(resolved.server, resolved.tool, result)
|
|
97
|
+
},
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function resolveToolArgs(server: string, tool: string): { server: string; tool: string } {
|
|
102
|
+
const parsed = parseNamespacedTool(tool)
|
|
103
|
+
return parsed ?? { server, tool }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function unknownServerMessage(manager: McpManager, server: string): string {
|
|
107
|
+
const available = manager
|
|
108
|
+
.listServers()
|
|
109
|
+
.filter((entry) => entry.connected)
|
|
110
|
+
.map((entry) => entry.name)
|
|
111
|
+
.join(', ')
|
|
112
|
+
return `Unknown MCP server ${JSON.stringify(server)}. Available servers: ${available || 'none'}.`
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function safeListTools(connection: McpConnection): Promise<McpToolInfo[]> {
|
|
116
|
+
try {
|
|
117
|
+
return await connection.listTools()
|
|
118
|
+
} catch (cause) {
|
|
119
|
+
throw new Error(`MCP list tools failed: ${sanitizeMcpError(errorMessage(cause))}`)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function safeCallTool(
|
|
124
|
+
connection: McpConnection,
|
|
125
|
+
tool: string,
|
|
126
|
+
args: Record<string, unknown>,
|
|
127
|
+
): Promise<CallToolResult> {
|
|
128
|
+
try {
|
|
129
|
+
return await connection.callTool(tool, args)
|
|
130
|
+
} catch (cause) {
|
|
131
|
+
throw new Error(`MCP call failed: ${sanitizeMcpError(errorMessage(cause))}`)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function mapCallToolResult(server: string, tool: string, result: CallToolResult): ToolResult {
|
|
136
|
+
const content = result.content.map(mapMcpContentPart)
|
|
137
|
+
if (result.isError === true) {
|
|
138
|
+
return {
|
|
139
|
+
content: content.map((part) =>
|
|
140
|
+
part.type === 'text' ? { type: 'text' as const, text: `MCP tool error: ${sanitizeMcpError(part.text)}` } : part,
|
|
141
|
+
),
|
|
142
|
+
details: { server, tool, isError: true },
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return { content, details: { server, tool, isError: false } }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function mapMcpContentPart(part: CallToolResult['content'][number]): ContentPart {
|
|
149
|
+
if (part.type === 'text' && typeof part.text === 'string') return { type: 'text', text: part.text }
|
|
150
|
+
if (part.type === 'image' && typeof part.data === 'string' && typeof part.mimeType === 'string') {
|
|
151
|
+
return { type: 'image', mimeType: part.mimeType, data: part.data }
|
|
152
|
+
}
|
|
153
|
+
return { type: 'text', text: summarizeUnsupportedPart(part) }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function summarizeUnsupportedPart(part: CallToolResult['content'][number]): string {
|
|
157
|
+
const type = typeof part.type === 'string' ? part.type : 'unknown'
|
|
158
|
+
const uri = readNestedString(part, 'resource', 'uri') ?? readString(part, 'uri')
|
|
159
|
+
if (uri !== undefined) return `[mcp:${type} ${uri}]`
|
|
160
|
+
const mimeType = readNestedString(part, 'resource', 'mimeType') ?? readString(part, 'mimeType')
|
|
161
|
+
if (mimeType !== undefined) return `[mcp:${type} ${mimeType}]`
|
|
162
|
+
return `[mcp:${type} omitted]`
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function sanitizeMcpError(raw: string): string {
|
|
166
|
+
const scrubbed = raw
|
|
167
|
+
.replace(/\b([A-Z_][A-Z0-9_]*)=\S+/g, '$1=<redacted>')
|
|
168
|
+
.replace(/(?:\/[\w.-]+)+/g, '<path>')
|
|
169
|
+
.replace(/\b[A-Za-z]:\\[^\s]+/g, '<path>')
|
|
170
|
+
return scrubbed.length <= 500 ? scrubbed : `${scrubbed.slice(0, 497)}...`
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function errorMessage(cause: unknown): string {
|
|
174
|
+
return cause instanceof Error ? cause.message : String(cause)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function readString(value: unknown, key: string): string | undefined {
|
|
178
|
+
if (typeof value !== 'object' || value === null) return undefined
|
|
179
|
+
const found = (value as Record<string, unknown>)[key]
|
|
180
|
+
return typeof found === 'string' ? found : undefined
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function readNestedString(value: unknown, key: string, nestedKey: string): string | undefined {
|
|
184
|
+
if (typeof value !== 'object' || value === null) return undefined
|
|
185
|
+
return readString((value as Record<string, unknown>)[key], nestedKey)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function textResult(text: string): ToolResult {
|
|
189
|
+
return { content: [{ type: 'text', text }] }
|
|
190
|
+
}
|
|
@@ -16,6 +16,13 @@ export const CORE_PERMISSIONS = {
|
|
|
16
16
|
// an operator may grant guest channelRespond to let strangers drive masked
|
|
17
17
|
// turns, but session lifecycle control stays member-and-up.
|
|
18
18
|
sessionControl: 'session.control',
|
|
19
|
+
// Operate-the-agent tier: gates the /reload and /restart channel commands
|
|
20
|
+
// (both the text-prefix and native-slash paths in the router). Strictly
|
|
21
|
+
// above sessionControl — reloading config or restarting the container
|
|
22
|
+
// mutates global agent state and drops every in-flight session, so it is
|
|
23
|
+
// owner+trusted only (NOT member). A member who can /stop a turn must not
|
|
24
|
+
// be able to bounce the whole container.
|
|
25
|
+
sessionAdmin: 'session.admin',
|
|
19
26
|
cronSchedule: 'cron.schedule',
|
|
20
27
|
cronModify: 'cron.modify',
|
|
21
28
|
subagentSpawn: 'subagent.spawn',
|
|
@@ -70,6 +77,7 @@ export const BUILTIN_ROLES: Readonly<Record<BuiltinRoleName, BuiltinRoleSpec>> =
|
|
|
70
77
|
permissions: [
|
|
71
78
|
CORE_PERMISSIONS.channelRespond,
|
|
72
79
|
CORE_PERMISSIONS.sessionControl,
|
|
80
|
+
CORE_PERMISSIONS.sessionAdmin,
|
|
73
81
|
CORE_PERMISSIONS.cronSchedule,
|
|
74
82
|
CORE_PERMISSIONS.cronModify,
|
|
75
83
|
CORE_PERMISSIONS.subagentSpawn,
|
|
@@ -89,6 +97,7 @@ export const BUILTIN_ROLES: Readonly<Record<BuiltinRoleName, BuiltinRoleSpec>> =
|
|
|
89
97
|
permissions: [
|
|
90
98
|
CORE_PERMISSIONS.channelRespond,
|
|
91
99
|
CORE_PERMISSIONS.sessionControl,
|
|
100
|
+
CORE_PERMISSIONS.sessionAdmin,
|
|
92
101
|
CORE_PERMISSIONS.cronSchedule,
|
|
93
102
|
CORE_PERMISSIONS.subagentSpawn,
|
|
94
103
|
CORE_PERMISSIONS.subagentCancel,
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ReloadResult } from './types'
|
|
2
|
+
|
|
3
|
+
// Human-facing /reload reply for Slack/Discord. Separate from reload-tool.ts's
|
|
4
|
+
// model-facing formatter on purpose — different audience, different shape.
|
|
5
|
+
export function formatChannelReloadSummary(results: readonly ReloadResult[]): string {
|
|
6
|
+
if (results.length === 0) return 'Nothing to reload.'
|
|
7
|
+
const failed = results.filter((r) => !r.ok).length
|
|
8
|
+
const header =
|
|
9
|
+
failed === 0
|
|
10
|
+
? `Reloaded ${results.length} subsystem(s).`
|
|
11
|
+
: `Reloaded ${results.length} subsystem(s); ${failed} failed.`
|
|
12
|
+
const lines = results.map((r) => (r.ok ? `• ${r.scope}: ${r.summary}` : `• ${r.scope}: failed — ${r.reason}`))
|
|
13
|
+
return [header, ...lines].join('\n')
|
|
14
|
+
}
|
package/src/reload/index.ts
CHANGED