ethagent 0.2.0 → 1.0.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 (143) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +114 -32
  3. package/bin/ethagent.js +11 -2
  4. package/package.json +30 -8
  5. package/src/app/FirstRun.tsx +412 -0
  6. package/src/app/hooks/useCancelRequest.ts +22 -0
  7. package/src/app/hooks/useDoublePress.ts +46 -0
  8. package/src/app/hooks/useExitOnCtrlC.ts +36 -0
  9. package/src/app/input/AppInputProvider.tsx +116 -0
  10. package/src/app/input/appInputParser.ts +279 -0
  11. package/src/app/keybindings/KeybindingProvider.tsx +134 -0
  12. package/src/app/keybindings/resolver.ts +42 -0
  13. package/src/app/keybindings/types.ts +26 -0
  14. package/src/chat/ChatBottomPane.tsx +280 -0
  15. package/src/chat/ChatInput.tsx +722 -0
  16. package/src/chat/ChatScreen.tsx +1575 -0
  17. package/src/chat/ContextLimitView.tsx +95 -0
  18. package/src/chat/ContinuityEditReviewView.tsx +48 -0
  19. package/src/chat/ConversationStack.tsx +47 -0
  20. package/src/chat/CopyPicker.tsx +52 -0
  21. package/src/chat/MessageList.tsx +609 -0
  22. package/src/chat/PermissionPrompt.tsx +153 -0
  23. package/src/chat/PermissionsView.tsx +159 -0
  24. package/src/chat/PlanApprovalView.tsx +91 -0
  25. package/src/chat/ResumeView.tsx +267 -0
  26. package/src/chat/RewindView.tsx +386 -0
  27. package/src/chat/SessionStatus.tsx +51 -0
  28. package/src/chat/TranscriptView.tsx +202 -0
  29. package/src/chat/chatInputState.ts +247 -0
  30. package/src/chat/chatPaste.ts +49 -0
  31. package/src/chat/chatScreenUtils.ts +187 -0
  32. package/src/chat/chatSessionState.ts +142 -0
  33. package/src/chat/chatTurnOrchestrator.ts +701 -0
  34. package/src/chat/commands.ts +673 -0
  35. package/src/chat/textCursor.ts +202 -0
  36. package/src/chat/toolResultDisplay.ts +8 -0
  37. package/src/chat/transcriptViewport.ts +247 -0
  38. package/src/cli/ResetConfirmView.tsx +61 -0
  39. package/src/cli/main.tsx +177 -0
  40. package/src/cli/preview.tsx +19 -0
  41. package/src/cli/reset.ts +106 -0
  42. package/src/identity/continuity/editor.ts +149 -0
  43. package/src/identity/continuity/envelope.ts +345 -0
  44. package/src/identity/continuity/history.ts +153 -0
  45. package/src/identity/continuity/privateEdit.ts +334 -0
  46. package/src/identity/continuity/publicSkills.ts +173 -0
  47. package/src/identity/continuity/snapshots.ts +183 -0
  48. package/src/identity/continuity/storage.ts +507 -0
  49. package/src/identity/crypto/backupEnvelope.ts +486 -0
  50. package/src/identity/crypto/eth.ts +137 -0
  51. package/src/identity/hub/IdentityHub.tsx +868 -0
  52. package/src/identity/hub/identityHubEffects.ts +1146 -0
  53. package/src/identity/hub/identityHubModel.ts +291 -0
  54. package/src/identity/hub/identityHubReducer.ts +212 -0
  55. package/src/identity/hub/screens/BusyScreen.tsx +26 -0
  56. package/src/identity/hub/screens/ContinuityDashboardScreen.tsx +144 -0
  57. package/src/identity/hub/screens/CreateFlow.tsx +206 -0
  58. package/src/identity/hub/screens/DetailsScreen.tsx +64 -0
  59. package/src/identity/hub/screens/EditProfileFlow.tsx +145 -0
  60. package/src/identity/hub/screens/ErrorScreen.tsx +35 -0
  61. package/src/identity/hub/screens/IdentitySummary.tsx +70 -0
  62. package/src/identity/hub/screens/MenuScreen.tsx +117 -0
  63. package/src/identity/hub/screens/NetworkScreen.tsx +41 -0
  64. package/src/identity/hub/screens/RebackupStorageScreen.tsx +50 -0
  65. package/src/identity/hub/screens/RecoveryConfirmScreen.tsx +85 -0
  66. package/src/identity/hub/screens/RestoreFlow.tsx +206 -0
  67. package/src/identity/hub/screens/StorageCredentialScreen.tsx +128 -0
  68. package/src/identity/hub/screens/WalletApprovalScreen.tsx +43 -0
  69. package/src/identity/profile/imagePicker.ts +180 -0
  70. package/src/identity/registry/erc8004.ts +1106 -0
  71. package/src/identity/registry/registryConfig.ts +69 -0
  72. package/src/identity/storage/ipfs.ts +212 -0
  73. package/src/identity/storage/pinataJwt.ts +53 -0
  74. package/src/identity/wallet/browserWallet.ts +393 -0
  75. package/src/identity/wallet/wallet-page/wallet.html +1082 -0
  76. package/src/mcp/approvals.ts +113 -0
  77. package/src/mcp/config.ts +235 -0
  78. package/src/mcp/manager.ts +541 -0
  79. package/src/mcp/names.ts +19 -0
  80. package/src/mcp/output.ts +96 -0
  81. package/src/models/ModelPicker.tsx +1446 -0
  82. package/src/models/catalog.ts +296 -0
  83. package/src/models/huggingface.ts +651 -0
  84. package/src/models/llamacpp.ts +810 -0
  85. package/src/models/llamacppPreflight.ts +150 -0
  86. package/src/models/modelDisplay.ts +105 -0
  87. package/src/models/modelPickerOptions.ts +421 -0
  88. package/src/models/modelRecommendation.ts +140 -0
  89. package/src/models/runtimeDetection.ts +81 -0
  90. package/src/models/uncensoredCatalog.ts +86 -0
  91. package/src/providers/anthropic.ts +259 -0
  92. package/src/providers/contracts.ts +62 -0
  93. package/src/providers/errors.ts +62 -0
  94. package/src/providers/gemini.ts +152 -0
  95. package/src/providers/openai-chat.ts +472 -0
  96. package/src/providers/registry.ts +42 -0
  97. package/src/providers/retry.ts +58 -0
  98. package/src/providers/sse.ts +93 -0
  99. package/src/runtime/compaction.ts +389 -0
  100. package/src/runtime/cwd.ts +43 -0
  101. package/src/runtime/sessionMode.ts +55 -0
  102. package/src/runtime/systemPrompt.ts +209 -0
  103. package/src/runtime/toolClaimGuards.ts +143 -0
  104. package/src/runtime/toolExecution.ts +304 -0
  105. package/src/runtime/toolIntent.ts +163 -0
  106. package/src/runtime/turn.ts +858 -0
  107. package/src/storage/atomicWrite.ts +68 -0
  108. package/src/storage/config.ts +189 -0
  109. package/src/storage/factoryReset.ts +130 -0
  110. package/src/storage/history.ts +58 -0
  111. package/src/storage/identity.ts +99 -0
  112. package/src/storage/permissions.ts +76 -0
  113. package/src/storage/rewind.ts +246 -0
  114. package/src/storage/secrets.ts +181 -0
  115. package/src/storage/sessionExport.ts +49 -0
  116. package/src/storage/sessions.ts +482 -0
  117. package/src/tools/bashSafety.ts +174 -0
  118. package/src/tools/bashTool.ts +140 -0
  119. package/src/tools/changeDirectoryTool.ts +213 -0
  120. package/src/tools/contracts.ts +179 -0
  121. package/src/tools/deleteFileTool.ts +111 -0
  122. package/src/tools/editTool.ts +160 -0
  123. package/src/tools/editUtils.ts +170 -0
  124. package/src/tools/listDirectoryTool.ts +55 -0
  125. package/src/tools/mcpResourceTools.ts +95 -0
  126. package/src/tools/permissionRules.ts +85 -0
  127. package/src/tools/privateContinuityEditTool.ts +178 -0
  128. package/src/tools/privateContinuityReadTool.ts +107 -0
  129. package/src/tools/readTool.ts +85 -0
  130. package/src/tools/registry.ts +67 -0
  131. package/src/tools/writeFileTool.ts +142 -0
  132. package/src/ui/BrandSplash.tsx +193 -0
  133. package/src/ui/ProgressBar.tsx +34 -0
  134. package/src/ui/Select.tsx +143 -0
  135. package/src/ui/Spinner.tsx +269 -0
  136. package/src/ui/Surface.tsx +47 -0
  137. package/src/ui/TextInput.tsx +97 -0
  138. package/src/ui/theme.ts +59 -0
  139. package/src/utils/clipboard.ts +216 -0
  140. package/src/utils/markdownSegments.ts +51 -0
  141. package/src/utils/messages.ts +35 -0
  142. package/src/utils/withRetry.ts +280 -0
  143. package/src/cli.tsx +0 -147
@@ -0,0 +1,541 @@
1
+ import { Ajv } from 'ajv'
2
+ import { z } from 'zod'
3
+ import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'
4
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js'
5
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
6
+ import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'
7
+ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
8
+ import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'
9
+ import type { Tool, ToolResult } from '../tools/contracts.js'
10
+ import {
11
+ addMcpServerConfig,
12
+ loadMcpConfigs,
13
+ mcpServerTransport,
14
+ parseMcpServerConfigJson,
15
+ type McpConfigIssue,
16
+ type McpServerConfig,
17
+ type ScopedMcpServerConfig,
18
+ } from './config.js'
19
+ import {
20
+ getProjectMcpDecision,
21
+ isMcpServerDisabled,
22
+ setMcpServerEnabled,
23
+ setProjectMcpDecision,
24
+ } from './approvals.js'
25
+ import { buildMcpToolName, normalizeNameForMcp, parseMcpToolName } from './names.js'
26
+ import {
27
+ formatMcpCallResult,
28
+ formatMcpResourceResult,
29
+ promptMessagesToText,
30
+ truncateMcpOutput,
31
+ } from './output.js'
32
+
33
+ const MCP_CONNECT_TIMEOUT_MS = 10_000
34
+ const MCP_LIST_TIMEOUT_MS = 10_000
35
+ const MCP_TOOL_TIMEOUT_MS = 120_000
36
+
37
+ type ListedMcpTool = {
38
+ name: string
39
+ description?: string
40
+ inputSchema: {
41
+ type: 'object'
42
+ properties?: Record<string, unknown>
43
+ required?: string[]
44
+ [key: string]: unknown
45
+ }
46
+ annotations?: {
47
+ readOnlyHint?: boolean
48
+ destructiveHint?: boolean
49
+ openWorldHint?: boolean
50
+ }
51
+ }
52
+
53
+ export type McpResourceInfo = {
54
+ server: string
55
+ uri: string
56
+ name: string
57
+ description?: string
58
+ mimeType?: string
59
+ }
60
+
61
+ export type McpPromptInfo = {
62
+ server: string
63
+ promptName: string
64
+ slashName: string
65
+ description?: string
66
+ arguments?: Array<{ name: string; required?: boolean; description?: string }>
67
+ }
68
+
69
+ export type McpServerSnapshot = {
70
+ name: string
71
+ normalizedName: string
72
+ scope: 'user' | 'project'
73
+ transport: 'stdio' | 'http' | 'sse'
74
+ status: 'pending' | 'connected' | 'failed' | 'disabled' | 'rejected'
75
+ tools: number
76
+ resources: number
77
+ prompts: number
78
+ message?: string
79
+ configHash: string
80
+ }
81
+
82
+ export type McpSnapshot = {
83
+ servers: McpServerSnapshot[]
84
+ issues: McpConfigIssue[]
85
+ prompts: McpPromptInfo[]
86
+ }
87
+
88
+ export const EMPTY_MCP_SNAPSHOT: McpSnapshot = { servers: [], issues: [], prompts: [] }
89
+
90
+ export type McpRuntime = {
91
+ callTool(name: string, input: Record<string, unknown>, signal?: AbortSignal): Promise<ToolResult>
92
+ listResources(serverName?: string): Promise<string>
93
+ readResource(serverName: string, uri: string, signal?: AbortSignal): Promise<string>
94
+ }
95
+
96
+ type ConnectedMcpServer = {
97
+ name: string
98
+ normalizedName: string
99
+ config: ScopedMcpServerConfig
100
+ client: Client
101
+ transport: Transport
102
+ tools: ListedMcpTool[]
103
+ resources: McpResourceInfo[]
104
+ prompts: McpPromptInfo[]
105
+ }
106
+
107
+ type ToolIndexEntry = {
108
+ connection: ConnectedMcpServer
109
+ tool: ListedMcpTool
110
+ }
111
+
112
+ const mcpInputSchema = z.object({}).passthrough()
113
+ const ajv = new Ajv({ strict: false })
114
+
115
+ export class McpManager implements McpRuntime {
116
+ private cwd: string
117
+ private closed = false
118
+ private snapshot: McpSnapshot = EMPTY_MCP_SNAPSHOT
119
+ private tools: Tool[] = []
120
+ private connections = new Map<string, ConnectedMcpServer>()
121
+ private toolIndex = new Map<string, ToolIndexEntry>()
122
+
123
+ constructor(
124
+ cwd: string,
125
+ private readonly onChange: (snapshot: McpSnapshot) => void,
126
+ ) {
127
+ this.cwd = cwd
128
+ }
129
+
130
+ currentSnapshot(): McpSnapshot {
131
+ return this.snapshot
132
+ }
133
+
134
+ getTools(): Tool[] {
135
+ return this.tools
136
+ }
137
+
138
+ getPromptSuggestions(): Array<{ name: string; summary: string; completion: string; executeOnEnter: boolean }> {
139
+ return this.snapshot.prompts.map(prompt => ({
140
+ name: prompt.slashName.slice(1),
141
+ summary: prompt.description ?? `MCP prompt from ${prompt.server}`,
142
+ completion: `${prompt.slashName} `,
143
+ executeOnEnter: false,
144
+ }))
145
+ }
146
+
147
+ async refresh(cwd = this.cwd): Promise<void> {
148
+ if (this.closed) return
149
+ this.cwd = cwd
150
+ await this.closeConnections()
151
+ if (this.closed) return
152
+
153
+ const loaded = await loadMcpConfigs(this.cwd)
154
+ const statuses: McpServerSnapshot[] = []
155
+ const promptInfos: McpPromptInfo[] = []
156
+ const nextTools: Tool[] = []
157
+ const seenNormalized = new Set<string>()
158
+
159
+ for (const server of loaded.servers) {
160
+ const normalizedName = normalizeNameForMcp(server.name)
161
+ const base: Omit<McpServerSnapshot, 'status' | 'tools' | 'resources' | 'prompts'> = {
162
+ name: server.name,
163
+ normalizedName,
164
+ scope: server.scope,
165
+ transport: mcpServerTransport(server.config),
166
+ configHash: server.configHash,
167
+ }
168
+
169
+ if (seenNormalized.has(normalizedName)) {
170
+ statuses.push({ ...base, status: 'failed', tools: 0, resources: 0, prompts: 0, message: 'normalized server name collides with another MCP server' })
171
+ continue
172
+ }
173
+ seenNormalized.add(normalizedName)
174
+
175
+ if (await isMcpServerDisabled(this.cwd, server.name)) {
176
+ statuses.push({ ...base, status: 'disabled', tools: 0, resources: 0, prompts: 0 })
177
+ continue
178
+ }
179
+
180
+ if (server.scope === 'project') {
181
+ const decision = await getProjectMcpDecision({
182
+ workspaceRoot: this.cwd,
183
+ serverName: server.name,
184
+ configHash: server.configHash,
185
+ })
186
+ if (decision === 'rejected') {
187
+ statuses.push({ ...base, status: 'rejected', tools: 0, resources: 0, prompts: 0, message: 'project server rejected' })
188
+ continue
189
+ }
190
+ if (decision !== 'approved') {
191
+ statuses.push({ ...base, status: 'pending', tools: 0, resources: 0, prompts: 0, message: 'project server needs approval' })
192
+ continue
193
+ }
194
+ }
195
+
196
+ const connected = await this.connectServer(server, normalizedName)
197
+ if (!connected.ok) {
198
+ statuses.push({ ...base, status: 'failed', tools: 0, resources: 0, prompts: 0, message: connected.error })
199
+ continue
200
+ }
201
+
202
+ this.connections.set(normalizedName, connected.server)
203
+ for (const remoteTool of connected.server.tools) {
204
+ const wrappedTool = this.wrapTool(connected.server, remoteTool)
205
+ this.toolIndex.set(wrappedTool.name, { connection: connected.server, tool: remoteTool })
206
+ nextTools.push(wrappedTool)
207
+ }
208
+ promptInfos.push(...connected.server.prompts)
209
+ statuses.push({
210
+ ...base,
211
+ status: 'connected',
212
+ tools: connected.server.tools.length,
213
+ resources: connected.server.resources.length,
214
+ prompts: connected.server.prompts.length,
215
+ })
216
+ }
217
+
218
+ this.tools = nextTools
219
+ this.snapshot = { servers: statuses, issues: loaded.issues, prompts: promptInfos }
220
+ if (!this.closed) this.onChange(this.snapshot)
221
+ }
222
+
223
+ async approveServer(serverName: string): Promise<string> {
224
+ const loaded = await loadMcpConfigs(this.cwd)
225
+ const server = findScopedServer(loaded.servers, serverName)
226
+ if (!server) return `MCP server "${serverName}" was not found.`
227
+ if (server.scope !== 'project') return `MCP server "${server.name}" is user-scoped and does not need project approval.`
228
+ await setProjectMcpDecision({
229
+ workspaceRoot: this.cwd,
230
+ serverName: server.name,
231
+ configHash: server.configHash,
232
+ decision: 'approved',
233
+ })
234
+ await this.refresh()
235
+ return `approved MCP project server "${server.name}".`
236
+ }
237
+
238
+ async rejectServer(serverName: string): Promise<string> {
239
+ const loaded = await loadMcpConfigs(this.cwd)
240
+ const server = findScopedServer(loaded.servers, serverName)
241
+ if (!server) return `MCP server "${serverName}" was not found.`
242
+ if (server.scope !== 'project') return `MCP server "${server.name}" is user-scoped; disable it instead.`
243
+ await setProjectMcpDecision({
244
+ workspaceRoot: this.cwd,
245
+ serverName: server.name,
246
+ configHash: server.configHash,
247
+ decision: 'rejected',
248
+ })
249
+ await this.refresh()
250
+ return `rejected MCP project server "${server.name}".`
251
+ }
252
+
253
+ async setEnabled(serverName: string, enabled: boolean): Promise<string> {
254
+ const loaded = await loadMcpConfigs(this.cwd)
255
+ const server = findScopedServer(loaded.servers, serverName)
256
+ if (!server) return `MCP server "${serverName}" was not found.`
257
+ await setMcpServerEnabled({ workspaceRoot: this.cwd, serverName: server.name, enabled })
258
+ await this.refresh()
259
+ return `${enabled ? 'enabled' : 'disabled'} MCP server "${server.name}".`
260
+ }
261
+
262
+ async reconnect(serverName?: string): Promise<string> {
263
+ await this.refresh()
264
+ if (!serverName || serverName === 'all') return 'reconnected MCP servers.'
265
+ const server = findServerSnapshot(this.snapshot.servers, serverName)
266
+ return server ? `reconnected MCP server "${server.name}".` : `MCP server "${serverName}" was not found.`
267
+ }
268
+
269
+ async addJson(name: string, json: string, scope: 'user' | 'project'): Promise<string> {
270
+ const config = parseMcpServerConfigJson(json)
271
+ const filePath = await addMcpServerConfig({ cwd: this.cwd, scope, name, config })
272
+ await this.refresh()
273
+ return `added MCP server "${name}" to ${scope} config: ${filePath}`
274
+ }
275
+
276
+ renderStatus(): string {
277
+ const lines: string[] = ['mcp servers:']
278
+ if (this.snapshot.servers.length === 0) {
279
+ lines.push(' none configured. use /mcp add-json <name> <json>')
280
+ } else {
281
+ for (const server of this.snapshot.servers) {
282
+ const counts = server.status === 'connected'
283
+ ? ` · ${server.tools} tools, ${server.resources} resources, ${server.prompts} prompts`
284
+ : server.message ? ` · ${server.message}` : ''
285
+ lines.push(` ${server.name} ${server.status} ${server.scope}/${server.transport}${counts}`)
286
+ }
287
+ }
288
+ if (this.snapshot.issues.length > 0) {
289
+ lines.push('', 'mcp config notes:')
290
+ for (const issue of this.snapshot.issues) {
291
+ const server = issue.serverName ? ` ${issue.serverName}` : ''
292
+ lines.push(` ${issue.severity}${server}: ${issue.message}`)
293
+ }
294
+ }
295
+ return lines.join('\n')
296
+ }
297
+
298
+ async runPromptSlash(name: string, argsText: string, signal?: AbortSignal): Promise<string | null> {
299
+ const parsed = parseMcpToolName(name)
300
+ if (!parsed) return null
301
+ const connection = this.connections.get(parsed.serverName)
302
+ if (!connection) return null
303
+ const prompt = connection.prompts.find(entry => normalizeNameForMcp(entry.promptName) === parsed.toolName)
304
+ if (!prompt) return null
305
+ const args = parsePromptArgs(argsText)
306
+ const result = await connection.client.getPrompt(
307
+ { name: prompt.promptName, arguments: Object.keys(args).length > 0 ? args : undefined },
308
+ { signal, timeout: MCP_TOOL_TIMEOUT_MS },
309
+ )
310
+ return promptMessagesToText(result)
311
+ }
312
+
313
+ async callTool(name: string, input: Record<string, unknown>, signal?: AbortSignal): Promise<ToolResult> {
314
+ const entry = this.toolIndex.get(name)
315
+ if (!entry) {
316
+ return { ok: false, summary: `${name} not connected`, content: `MCP tool "${name}" is not connected.` }
317
+ }
318
+ try {
319
+ const result = await entry.connection.client.callTool(
320
+ { name: entry.tool.name, arguments: input },
321
+ CallToolResultSchema,
322
+ { signal, timeout: MCP_TOOL_TIMEOUT_MS },
323
+ )
324
+ const formatted = formatMcpCallResult(result)
325
+ return {
326
+ ok: formatted.ok,
327
+ summary: `${entry.connection.name}/${entry.tool.name}`,
328
+ content: formatted.content,
329
+ }
330
+ } catch (err: unknown) {
331
+ return {
332
+ ok: false,
333
+ summary: `${entry.connection.name}/${entry.tool.name} failed`,
334
+ content: (err as Error).message || 'MCP tool failed',
335
+ }
336
+ }
337
+ }
338
+
339
+ async listResources(serverName?: string): Promise<string> {
340
+ const connections = serverName
341
+ ? [this.getConnection(serverName)].filter((conn): conn is ConnectedMcpServer => Boolean(conn))
342
+ : [...this.connections.values()]
343
+ if (connections.length === 0) return serverName ? `MCP server "${serverName}" is not connected.` : 'No connected MCP servers.'
344
+ const lines: string[] = []
345
+ for (const connection of connections) {
346
+ lines.push(`${connection.name}:`)
347
+ if (connection.resources.length === 0) {
348
+ lines.push(' no resources')
349
+ } else {
350
+ for (const resource of connection.resources) {
351
+ const mime = resource.mimeType ? ` ${resource.mimeType}` : ''
352
+ const desc = resource.description ? ` · ${resource.description}` : ''
353
+ lines.push(` ${resource.uri}${mime}${desc}`)
354
+ }
355
+ }
356
+ }
357
+ return lines.join('\n')
358
+ }
359
+
360
+ async readResource(serverName: string, uri: string, signal?: AbortSignal): Promise<string> {
361
+ const connection = this.getConnection(serverName)
362
+ if (!connection) return `MCP server "${serverName}" is not connected.`
363
+ const result = await connection.client.readResource({ uri }, { signal, timeout: MCP_TOOL_TIMEOUT_MS })
364
+ return formatMcpResourceResult(result)
365
+ }
366
+
367
+ async close(): Promise<void> {
368
+ this.closed = true
369
+ await this.closeConnections()
370
+ }
371
+
372
+ private async connectServer(
373
+ config: ScopedMcpServerConfig,
374
+ normalizedName: string,
375
+ ): Promise<{ ok: true; server: ConnectedMcpServer } | { ok: false; error: string }> {
376
+ const client = new Client(
377
+ { name: 'ethagent', version: '1.0.0' },
378
+ { capabilities: {} },
379
+ )
380
+ const transport = createTransport(config.config, this.cwd)
381
+ try {
382
+ await client.connect(transport, { timeout: MCP_CONNECT_TIMEOUT_MS })
383
+ const capabilities = client.getServerCapabilities()
384
+ const tools = capabilities?.tools
385
+ ? (await client.listTools(undefined, { timeout: MCP_LIST_TIMEOUT_MS })).tools as ListedMcpTool[]
386
+ : []
387
+ const resources = capabilities?.resources
388
+ ? (await client.listResources(undefined, { timeout: MCP_LIST_TIMEOUT_MS })).resources.map(resource => ({
389
+ server: config.name,
390
+ uri: resource.uri,
391
+ name: resource.name,
392
+ description: resource.description,
393
+ mimeType: resource.mimeType,
394
+ }))
395
+ : []
396
+ const prompts = capabilities?.prompts
397
+ ? (await client.listPrompts(undefined, { timeout: MCP_LIST_TIMEOUT_MS })).prompts.map(prompt => ({
398
+ server: config.name,
399
+ promptName: prompt.name,
400
+ slashName: `/${buildMcpToolName(config.name, prompt.name)}`,
401
+ description: prompt.description,
402
+ arguments: prompt.arguments,
403
+ }))
404
+ : []
405
+ return {
406
+ ok: true,
407
+ server: {
408
+ name: config.name,
409
+ normalizedName,
410
+ config,
411
+ client,
412
+ transport,
413
+ tools,
414
+ resources,
415
+ prompts,
416
+ },
417
+ }
418
+ } catch (err: unknown) {
419
+ await transport.close().catch(() => {})
420
+ return { ok: false, error: (err as Error).message || 'MCP connection failed' }
421
+ }
422
+ }
423
+
424
+ private wrapTool(connection: ConnectedMcpServer, tool: ListedMcpTool): Tool<typeof mcpInputSchema> {
425
+ const toolName = buildMcpToolName(connection.name, tool.name)
426
+ const validate = ajv.compile(tool.inputSchema)
427
+ const readOnly = tool.annotations?.readOnlyHint === true
428
+ return {
429
+ name: toolName,
430
+ kind: 'mcp',
431
+ readOnly,
432
+ description: tool.description ?? `MCP tool ${tool.name} from ${connection.name}`,
433
+ inputSchema: mcpInputSchema,
434
+ inputSchemaJson: normalizeInputSchemaJson(tool.inputSchema),
435
+ parse(input) {
436
+ const parsed = mcpInputSchema.parse(input)
437
+ if (!validate(parsed)) {
438
+ throw new Error(`MCP tool input failed schema validation: ${ajv.errorsText(validate.errors)}`)
439
+ }
440
+ return parsed
441
+ },
442
+ async buildPermissionRequest() {
443
+ return {
444
+ kind: 'mcp',
445
+ title: 'allow MCP tool?',
446
+ subtitle: `${connection.name} / ${tool.name}`,
447
+ serverName: connection.name,
448
+ normalizedServerName: connection.normalizedName,
449
+ toolName: tool.name,
450
+ toolKey: toolName,
451
+ readOnly,
452
+ destructive: tool.annotations?.destructiveHint === true,
453
+ openWorld: tool.annotations?.openWorldHint === true,
454
+ canPersistServer: true,
455
+ }
456
+ },
457
+ async execute(input, context) {
458
+ if (!context.mcp) {
459
+ return { ok: false, summary: `${toolName} unavailable`, content: 'MCP runtime is not available.' }
460
+ }
461
+ return context.mcp.callTool(toolName, input, context.abortSignal)
462
+ },
463
+ }
464
+ }
465
+
466
+ private getConnection(serverName: string): ConnectedMcpServer | undefined {
467
+ const normalized = normalizeNameForMcp(serverName)
468
+ return this.connections.get(normalized) ?? [...this.connections.values()].find(conn => conn.name === serverName)
469
+ }
470
+
471
+ private async closeConnections(): Promise<void> {
472
+ for (const connection of this.connections.values()) {
473
+ await connection.transport.close().catch(() => {})
474
+ }
475
+ this.connections.clear()
476
+ this.toolIndex.clear()
477
+ this.tools = []
478
+ }
479
+ }
480
+
481
+ function createTransport(config: McpServerConfig, cwd: string): Transport {
482
+ if (config.type === 'http') {
483
+ return new StreamableHTTPClientTransport(new URL(config.url), {
484
+ requestInit: config.headers ? { headers: config.headers } : undefined,
485
+ })
486
+ }
487
+ if (config.type === 'sse') {
488
+ return new SSEClientTransport(new URL(config.url), {
489
+ requestInit: config.headers ? { headers: config.headers } : undefined,
490
+ eventSourceInit: config.headers ? { fetch: (url, init) => fetch(url, { ...init, headers: config.headers }) } : undefined,
491
+ })
492
+ }
493
+ return new StdioClientTransport({
494
+ command: config.command,
495
+ args: config.args ?? [],
496
+ env: config.env ? mergeProcessEnv(config.env) : undefined,
497
+ cwd: config.cwd ?? cwd,
498
+ stderr: 'pipe',
499
+ })
500
+ }
501
+
502
+ function mergeProcessEnv(extra: Record<string, string>): Record<string, string> {
503
+ const env: Record<string, string> = {}
504
+ for (const [key, value] of Object.entries(process.env)) {
505
+ if (value !== undefined) env[key] = value
506
+ }
507
+ return { ...env, ...extra }
508
+ }
509
+
510
+ function normalizeInputSchemaJson(schema: ListedMcpTool['inputSchema']): Tool['inputSchemaJson'] {
511
+ return {
512
+ type: 'object',
513
+ properties: schema.properties,
514
+ required: schema.required,
515
+ oneOf: Array.isArray(schema.oneOf) ? schema.oneOf as Array<Record<string, unknown>> : undefined,
516
+ anyOf: Array.isArray(schema.anyOf) ? schema.anyOf as Array<Record<string, unknown>> : undefined,
517
+ additionalProperties: schema.additionalProperties as boolean | undefined,
518
+ }
519
+ }
520
+
521
+ function findScopedServer(servers: ScopedMcpServerConfig[], name: string): ScopedMcpServerConfig | undefined {
522
+ const normalized = normalizeNameForMcp(name)
523
+ return servers.find(server => server.name === name || normalizeNameForMcp(server.name) === normalized)
524
+ }
525
+
526
+ function findServerSnapshot(servers: McpServerSnapshot[], name: string): McpServerSnapshot | undefined {
527
+ const normalized = normalizeNameForMcp(name)
528
+ return servers.find(server => server.name === name || server.normalizedName === normalized)
529
+ }
530
+
531
+ function parsePromptArgs(value: string): Record<string, string> {
532
+ const args: Record<string, string> = {}
533
+ for (const token of value.trim().split(/\s+/).filter(Boolean)) {
534
+ const idx = token.indexOf('=')
535
+ if (idx === -1) continue
536
+ const key = token.slice(0, idx)
537
+ if (!key) continue
538
+ args[key] = token.slice(idx + 1)
539
+ }
540
+ return args
541
+ }
@@ -0,0 +1,19 @@
1
+ export function normalizeNameForMcp(name: string): string {
2
+ const normalized = name.trim().replace(/[^a-zA-Z0-9_-]+/g, '_').replace(/^_+|_+$/g, '')
3
+ return normalized || 'unnamed'
4
+ }
5
+
6
+ export function mcpToolPrefix(serverName: string): string {
7
+ return `mcp__${normalizeNameForMcp(serverName)}__`
8
+ }
9
+
10
+ export function buildMcpToolName(serverName: string, toolName: string): string {
11
+ return `${mcpToolPrefix(serverName)}${normalizeNameForMcp(toolName)}`
12
+ }
13
+
14
+ export function parseMcpToolName(value: string): { serverName: string; toolName: string } | null {
15
+ const parts = value.split('__')
16
+ const [prefix, serverName, ...toolNameParts] = parts
17
+ if (prefix !== 'mcp' || !serverName || toolNameParts.length === 0) return null
18
+ return { serverName, toolName: toolNameParts.join('__') }
19
+ }
@@ -0,0 +1,96 @@
1
+ export const MAX_MCP_OUTPUT_CHARS = 100_000
2
+
3
+ export function formatMcpCallResult(result: unknown): { ok: boolean; content: string } {
4
+ if (isRecord(result) && 'toolResult' in result) {
5
+ return { ok: true, content: truncateMcpOutput(formatUnknown(result.toolResult)) }
6
+ }
7
+
8
+ const isError = isRecord(result) && result.isError === true
9
+ const parts: string[] = []
10
+ if (isRecord(result) && Array.isArray(result.content)) {
11
+ for (const block of result.content) parts.push(formatContentBlock(block))
12
+ }
13
+ if (isRecord(result) && isRecord(result.structuredContent)) {
14
+ parts.push(`structuredContent:\n${JSON.stringify(result.structuredContent, null, 2)}`)
15
+ }
16
+ if (parts.length === 0) parts.push(formatUnknown(result))
17
+ const content = parts.filter(Boolean).join('\n\n')
18
+ return { ok: !isError, content: truncateMcpOutput(isError ? annotateMcpError(content) : content) }
19
+ }
20
+
21
+ export function formatMcpResourceResult(result: unknown): string {
22
+ if (!isRecord(result) || !Array.isArray(result.contents)) return truncateMcpOutput(formatUnknown(result))
23
+ return truncateMcpOutput(result.contents.map(content => {
24
+ if (!isRecord(content)) return formatUnknown(content)
25
+ const uri = typeof content.uri === 'string' ? content.uri : 'resource'
26
+ const mime = typeof content.mimeType === 'string' ? ` (${content.mimeType})` : ''
27
+ if (typeof content.text === 'string') return `${uri}${mime}\n${content.text}`
28
+ if (typeof content.blob === 'string') return `${uri}${mime}\n[binary blob ${content.blob.length} base64 chars]`
29
+ return `${uri}${mime}\n${formatUnknown(content)}`
30
+ }).join('\n\n'))
31
+ }
32
+
33
+ export function truncateMcpOutput(value: string): string {
34
+ if (value.length <= MAX_MCP_OUTPUT_CHARS) return value
35
+ return `${value.slice(0, MAX_MCP_OUTPUT_CHARS)}\n\n[OUTPUT TRUNCATED · exceeded ${MAX_MCP_OUTPUT_CHARS.toLocaleString()} characters. If this MCP server supports pagination or filters, call it again for a narrower result.]`
36
+ }
37
+
38
+ export function promptMessagesToText(result: unknown): string {
39
+ if (!isRecord(result) || !Array.isArray(result.messages)) return formatUnknown(result)
40
+ return result.messages.map(message => {
41
+ if (!isRecord(message)) return formatUnknown(message)
42
+ const role = typeof message.role === 'string' ? message.role : 'user'
43
+ const content = formatContentBlock(message.content)
44
+ return `${role}:\n${content}`
45
+ }).join('\n\n')
46
+ }
47
+
48
+ function formatContentBlock(block: unknown): string {
49
+ if (!isRecord(block)) return formatUnknown(block)
50
+ if (block.type === 'text' && typeof block.text === 'string') return block.text
51
+ if (block.type === 'image') {
52
+ const mime = typeof block.mimeType === 'string' ? block.mimeType : 'image'
53
+ const data = typeof block.data === 'string' ? ` ${block.data.length} base64 chars` : ''
54
+ return `[image ${mime}${data}]`
55
+ }
56
+ if (block.type === 'audio') {
57
+ const mime = typeof block.mimeType === 'string' ? block.mimeType : 'audio'
58
+ const data = typeof block.data === 'string' ? ` ${block.data.length} base64 chars` : ''
59
+ return `[audio ${mime}${data}]`
60
+ }
61
+ if (block.type === 'resource' && isRecord(block.resource)) {
62
+ const resource = block.resource
63
+ const uri = typeof resource.uri === 'string' ? resource.uri : 'resource'
64
+ if (typeof resource.text === 'string') return `${uri}\n${resource.text}`
65
+ if (typeof resource.blob === 'string') return `${uri}\n[binary blob ${resource.blob.length} base64 chars]`
66
+ }
67
+ if (block.type === 'resource_link') {
68
+ const name = typeof block.name === 'string' ? block.name : 'resource'
69
+ const uri = typeof block.uri === 'string' ? block.uri : ''
70
+ return `[resource link ${name}${uri ? ` ${uri}` : ''}]`
71
+ }
72
+ return formatUnknown(block)
73
+ }
74
+
75
+ function formatUnknown(value: unknown): string {
76
+ if (typeof value === 'string') return value
77
+ return JSON.stringify(value, null, 2) ?? String(value)
78
+ }
79
+
80
+ function annotateMcpError(content: string): string {
81
+ const lower = content.toLowerCase()
82
+ const looksRateLimited = lower.includes('rate limit') ||
83
+ lower.includes('too quickly') ||
84
+ lower.includes('429') ||
85
+ lower.includes('ddg detected an anomaly')
86
+
87
+ if (!looksRateLimited) return content
88
+ return [
89
+ content,
90
+ '[MCP server returned an upstream rate-limit or anti-abuse error; the MCP transport is still connected. Wait before retrying or use an API-key-backed search server for frequent searches.]',
91
+ ].join('\n\n')
92
+ }
93
+
94
+ function isRecord(value: unknown): value is Record<string, unknown> {
95
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
96
+ }