@bibobonking/opensol 3.0.3 → 3.1.1

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 (83) hide show
  1. package/.github/workflows/build.yml +60 -8
  2. package/CHANGELOG.md +21 -0
  3. package/README.md +2 -2
  4. package/desktop/README.md +39 -0
  5. package/desktop/loading/index.html +36 -0
  6. package/desktop/src-tauri/Cargo.lock +4301 -0
  7. package/desktop/src-tauri/Cargo.toml +23 -0
  8. package/desktop/src-tauri/binaries/.gitkeep +0 -0
  9. package/desktop/src-tauri/build.rs +3 -0
  10. package/desktop/src-tauri/capabilities/default.json +7 -0
  11. package/desktop/src-tauri/icons/128x128.png +0 -0
  12. package/desktop/src-tauri/icons/128x128@2x.png +0 -0
  13. package/desktop/src-tauri/icons/32x32.png +0 -0
  14. package/desktop/src-tauri/icons/64x64.png +0 -0
  15. package/desktop/src-tauri/icons/Square107x107Logo.png +0 -0
  16. package/desktop/src-tauri/icons/Square142x142Logo.png +0 -0
  17. package/desktop/src-tauri/icons/Square150x150Logo.png +0 -0
  18. package/desktop/src-tauri/icons/Square284x284Logo.png +0 -0
  19. package/desktop/src-tauri/icons/Square30x30Logo.png +0 -0
  20. package/desktop/src-tauri/icons/Square310x310Logo.png +0 -0
  21. package/desktop/src-tauri/icons/Square44x44Logo.png +0 -0
  22. package/desktop/src-tauri/icons/Square71x71Logo.png +0 -0
  23. package/desktop/src-tauri/icons/Square89x89Logo.png +0 -0
  24. package/desktop/src-tauri/icons/StoreLogo.png +0 -0
  25. package/desktop/src-tauri/icons/icon.icns +0 -0
  26. package/desktop/src-tauri/icons/icon.ico +0 -0
  27. package/desktop/src-tauri/icons/icon.png +0 -0
  28. package/desktop/src-tauri/src/lib.rs +210 -0
  29. package/desktop/src-tauri/src/main.rs +6 -0
  30. package/desktop/src-tauri/tauri.conf.json +48 -0
  31. package/dist/assets/index-CeN54A2N.css +1 -0
  32. package/dist/assets/index-fhIO9a7_.js +318 -0
  33. package/dist/index.html +4 -4
  34. package/index.html +2 -2
  35. package/package.json +2 -1
  36. package/server/README.md +2 -2
  37. package/server/daemon.mjs +138 -2
  38. package/server/lib/mcp-oauth.mjs +219 -0
  39. package/server/lib/mcp-oauth.test.mjs +175 -0
  40. package/server/lib/mcp.mjs +899 -0
  41. package/server/lib/mcp.test.mjs +46 -0
  42. package/server/lib/store.mjs +22 -0
  43. package/server/lib/tools.mjs +59 -1
  44. package/src/App.tsx +2 -0
  45. package/src/agent/tools.ts +6 -5
  46. package/src/components/CommandPalette.tsx +14 -1
  47. package/src/components/Composer.tsx +22 -38
  48. package/src/components/DiffView.tsx +98 -0
  49. package/src/components/Logo.tsx +1 -1
  50. package/src/components/McpPanel.tsx +687 -0
  51. package/src/components/MessageItem.tsx +62 -13
  52. package/src/components/ModelDeck.tsx +661 -0
  53. package/src/components/SessionsTab.tsx +206 -0
  54. package/src/components/SettingsModal.tsx +146 -71
  55. package/src/components/Sidebar.tsx +79 -24
  56. package/src/components/Splash.tsx +1 -1
  57. package/src/components/ToolCallCard.tsx +29 -7
  58. package/src/components/WorkspacePanel.tsx +16 -1
  59. package/src/core/kernel.ts +2 -0
  60. package/src/core/mcp-tools.ts +63 -0
  61. package/src/core/plugins/tools/fs.ts +24 -3
  62. package/src/core/services/agent-loop.ts +3 -1
  63. package/src/core/services/approval.ts +2 -1
  64. package/src/core/services/titles.ts +135 -0
  65. package/src/core/services/tools.ts +11 -8
  66. package/src/core/types.ts +4 -0
  67. package/src/data/providers.ts +1 -1
  68. package/src/index.css +97 -1
  69. package/src/lib/daemon.ts +38 -0
  70. package/src/lib/diff.ts +62 -0
  71. package/src/lib/mcp-config.test.mjs +47 -0
  72. package/src/lib/mcp-config.ts +203 -0
  73. package/src/lib/permissions.ts +6 -6
  74. package/src/store/chats.ts +9 -1
  75. package/src/store/mcp.ts +94 -0
  76. package/src/store/settings.ts +10 -7
  77. package/src/store/ui.ts +17 -0
  78. package/src/store/workspace.ts +26 -0
  79. package/src/types.ts +63 -2
  80. package/tsconfig.tsbuildinfo +1 -1
  81. package/dist/assets/index-BmN7pBji.css +0 -1
  82. package/dist/assets/index-DgNDjkOz.js +0 -301
  83. package/src/components/ModelPicker.tsx +0 -487
@@ -0,0 +1,899 @@
1
+ // Minimal Model Context Protocol client (stdio, Streamable HTTP, legacy HTTP+SSE, OAuth), zero dependencies.
2
+ // The daemon owns every connection: the browser only sees tool metadata and results.
3
+ import { spawn } from 'node:child_process'
4
+ import os from 'node:os'
5
+ import { HttpError } from './http.mjs'
6
+ import {
7
+ AuthRequiredError,
8
+ PendingAuthorizations,
9
+ buildAuthorizationUrl,
10
+ createPkce,
11
+ discover,
12
+ exchangeCode,
13
+ refreshTokens,
14
+ registerClient,
15
+ tokenIsFresh,
16
+ } from './mcp-oauth.mjs'
17
+
18
+ const PROTOCOL_VERSION = '2025-03-26'
19
+ const CONNECT_TIMEOUT_MS = 60_000
20
+ const REQUEST_TIMEOUT_MS = 120_000
21
+ const MAX_RESULT_CHARS = 200_000
22
+ const MASK = '••••••••'
23
+
24
+ let requestCounter = 0
25
+
26
+ function withTimeout(promise, ms, label) {
27
+ let timer
28
+ const timeout = new Promise((_, reject) => {
29
+ timer = setTimeout(() => reject(new Error(`${label} timed out after ${Math.round(ms / 1000)}s.`)), ms)
30
+ })
31
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer))
32
+ }
33
+
34
+ class RpcError extends Error {
35
+ constructor(error) {
36
+ super(error?.message || 'MCP request failed.')
37
+ this.code = error?.code
38
+ }
39
+ }
40
+
41
+ /** Newline-delimited JSON-RPC over a child process. */
42
+ class StdioTransport {
43
+ constructor(config, { onNotification, cwd }) {
44
+ this.config = config
45
+ this.onNotification = onNotification
46
+ this.cwd = cwd
47
+ this.pending = new Map()
48
+ this.buffer = ''
49
+ this.stderr = ''
50
+ this.closed = false
51
+ }
52
+
53
+ async start() {
54
+ const win = process.platform === 'win32'
55
+ const env = { ...process.env, ...(this.config.env ?? {}) }
56
+ this.child = spawn(this.config.command, this.config.args ?? [], {
57
+ cwd: this.cwd,
58
+ env,
59
+ shell: win,
60
+ stdio: ['pipe', 'pipe', 'pipe'],
61
+ windowsHide: true,
62
+ })
63
+ this.child.stdout.setEncoding('utf8')
64
+ this.child.stdout.on('data', (chunk) => this.#onData(chunk))
65
+ this.child.stderr.setEncoding('utf8')
66
+ this.child.stderr.on('data', (chunk) => {
67
+ this.stderr = (this.stderr + chunk).slice(-4000)
68
+ })
69
+ this.child.stdin.on('error', () => undefined)
70
+ await new Promise((resolve, reject) => {
71
+ this.child.once('spawn', resolve)
72
+ this.child.once('error', (error) => reject(new Error(`Could not start "${this.config.command}": ${error.message}`)))
73
+ })
74
+ this.child.on('exit', (code) => {
75
+ this.closed = true
76
+ const detail = this.stderr.trim().split('\n').slice(-3).join(' ').slice(0, 300)
77
+ const error = new Error(`The MCP server exited (code ${code ?? 'signal'}).${detail ? ` ${detail}` : ''}`)
78
+ for (const entry of this.pending.values()) entry.reject(error)
79
+ this.pending.clear()
80
+ this.onExit?.(error)
81
+ })
82
+ }
83
+
84
+ #onData(chunk) {
85
+ this.buffer += chunk
86
+ let index
87
+ while ((index = this.buffer.indexOf('\n')) >= 0) {
88
+ const line = this.buffer.slice(0, index).trim()
89
+ this.buffer = this.buffer.slice(index + 1)
90
+ if (!line) continue
91
+ let message
92
+ try {
93
+ message = JSON.parse(line)
94
+ } catch {
95
+ continue
96
+ }
97
+ this.#dispatch(message)
98
+ }
99
+ }
100
+
101
+ #dispatch(message) {
102
+ if (message && message.id !== undefined && (message.result !== undefined || message.error)) {
103
+ const entry = this.pending.get(message.id)
104
+ if (!entry) return
105
+ this.pending.delete(message.id)
106
+ if (message.error) entry.reject(new RpcError(message.error))
107
+ else entry.resolve(message.result)
108
+ return
109
+ }
110
+ if (message?.method && message.id !== undefined) {
111
+ // Server-initiated requests: answer pings, refuse everything else politely.
112
+ const reply =
113
+ message.method === 'ping'
114
+ ? { jsonrpc: '2.0', id: message.id, result: {} }
115
+ : { jsonrpc: '2.0', id: message.id, error: { code: -32601, message: 'Method not supported by OpenSol.' } }
116
+ this.#write(reply)
117
+ return
118
+ }
119
+ if (message?.method) this.onNotification?.(message.method, message.params)
120
+ }
121
+
122
+ #write(message) {
123
+ if (this.closed || !this.child?.stdin?.writable) throw new Error('The MCP server is not running.')
124
+ this.child.stdin.write(`${JSON.stringify(message)}\n`)
125
+ }
126
+
127
+ request(method, params, timeoutMs = REQUEST_TIMEOUT_MS) {
128
+ const id = ++requestCounter
129
+ const promise = new Promise((resolve, reject) => {
130
+ this.pending.set(id, { resolve, reject })
131
+ try {
132
+ this.#write({ jsonrpc: '2.0', id, method, params })
133
+ } catch (error) {
134
+ this.pending.delete(id)
135
+ reject(error)
136
+ }
137
+ })
138
+ return withTimeout(promise, timeoutMs, method).catch((error) => {
139
+ this.pending.delete(id)
140
+ throw error
141
+ })
142
+ }
143
+
144
+ notify(method, params) {
145
+ this.#write({ jsonrpc: '2.0', method, params })
146
+ }
147
+
148
+ async close() {
149
+ this.closed = true
150
+ const child = this.child
151
+ if (!child || child.exitCode !== null) return
152
+ try {
153
+ child.stdin.end()
154
+ } catch {
155
+ // Already closed.
156
+ }
157
+ await new Promise((resolve) => {
158
+ const timer = setTimeout(() => {
159
+ child.kill('SIGKILL')
160
+ resolve()
161
+ }, 1500)
162
+ child.once('exit', () => {
163
+ clearTimeout(timer)
164
+ resolve()
165
+ })
166
+ child.kill('SIGTERM')
167
+ })
168
+ }
169
+ }
170
+
171
+ async function readSse(response, wantedId) {
172
+ const reader = response.body.getReader()
173
+ const decoder = new TextDecoder()
174
+ let buffer = ''
175
+ for (;;) {
176
+ const { value, done } = await reader.read()
177
+ if (done) break
178
+ buffer += decoder.decode(value, { stream: true })
179
+ let boundary
180
+ while ((boundary = buffer.search(/\r?\n\r?\n/)) >= 0) {
181
+ const block = buffer.slice(0, boundary)
182
+ buffer = buffer.slice(boundary).replace(/^\r?\n\r?\n/, '')
183
+ const data = block
184
+ .split(/\r?\n/)
185
+ .filter((line) => line.startsWith('data:'))
186
+ .map((line) => line.slice(5).replace(/^ /, ''))
187
+ .join('\n')
188
+ if (!data) continue
189
+ let message
190
+ try {
191
+ message = JSON.parse(data)
192
+ } catch {
193
+ continue
194
+ }
195
+ if (message.id === wantedId && (message.result !== undefined || message.error)) {
196
+ reader.cancel().catch(() => undefined)
197
+ if (message.error) throw new RpcError(message.error)
198
+ return message.result
199
+ }
200
+ }
201
+ }
202
+ throw new Error('The MCP server closed the stream without answering.')
203
+ }
204
+
205
+ /** Parses a text/event-stream response into { event, data } records. */
206
+ async function* sseEvents(response, signal) {
207
+ const reader = response.body.getReader()
208
+ const decoder = new TextDecoder()
209
+ let buffer = ''
210
+ try {
211
+ for (;;) {
212
+ if (signal?.aborted) return
213
+ const { value, done } = await reader.read()
214
+ if (done) return
215
+ buffer += decoder.decode(value, { stream: true })
216
+ let boundary
217
+ while ((boundary = buffer.search(/\r?\n\r?\n/)) >= 0) {
218
+ const block = buffer.slice(0, boundary)
219
+ buffer = buffer.slice(boundary).replace(/^\r?\n\r?\n/, '')
220
+ let event = 'message'
221
+ const data = []
222
+ for (const line of block.split(/\r?\n/)) {
223
+ if (line.startsWith('event:')) event = line.slice(6).trim()
224
+ else if (line.startsWith('data:')) data.push(line.slice(5).replace(/^ /, ''))
225
+ }
226
+ if (data.length) yield { event, data: data.join('\n') }
227
+ }
228
+ }
229
+ } finally {
230
+ reader.cancel().catch(() => undefined)
231
+ }
232
+ }
233
+
234
+ function checkRemoteUrl(value) {
235
+ let parsed
236
+ try {
237
+ parsed = new URL(value)
238
+ } catch {
239
+ throw new Error('The MCP server URL is not valid.')
240
+ }
241
+ if (!['http:', 'https:'].includes(parsed.protocol)) throw new Error('MCP servers must use http or https.')
242
+ return parsed
243
+ }
244
+
245
+ /** Request headers for a remote server: OAuth bearer first, then user-defined headers. */
246
+ async function remoteHeaders(config, auth, extra) {
247
+ const bearer = auth ? await auth.header() : ''
248
+ return {
249
+ ...extra,
250
+ ...(bearer ? { Authorization: bearer } : {}),
251
+ ...(config.headers ?? {}),
252
+ }
253
+ }
254
+
255
+ /**
256
+ * fetch() with OAuth handling: a 401 triggers one token refresh and retry, then
257
+ * surfaces AuthRequiredError so the manager can ask the person to sign in.
258
+ */
259
+ async function authedFetch(url, build, auth) {
260
+ for (let attempt = 0; ; attempt += 1) {
261
+ const response = await fetch(url, await build())
262
+ if (response.status !== 401) return response
263
+ const challenge = response.headers.get('www-authenticate') ?? ''
264
+ await response.body?.cancel().catch(() => undefined)
265
+ if (auth && attempt === 0 && (await auth.refresh())) continue
266
+ throw new AuthRequiredError(challenge)
267
+ }
268
+ }
269
+
270
+ /** Streamable HTTP transport: one POST per message, JSON or SSE replies. */
271
+ class HttpTransport {
272
+ constructor(config, { auth } = {}) {
273
+ this.config = config
274
+ this.auth = auth
275
+ this.sessionId = ''
276
+ }
277
+
278
+ async start() {
279
+ checkRemoteUrl(this.config.url)
280
+ }
281
+
282
+ #headers() {
283
+ return remoteHeaders(this.config, this.auth, {
284
+ 'Content-Type': 'application/json',
285
+ Accept: 'application/json, text/event-stream',
286
+ 'MCP-Protocol-Version': PROTOCOL_VERSION,
287
+ ...(this.sessionId ? { 'Mcp-Session-Id': this.sessionId } : {}),
288
+ })
289
+ }
290
+
291
+ async request(method, params, timeoutMs = REQUEST_TIMEOUT_MS) {
292
+ const id = ++requestCounter
293
+ const controller = new AbortController()
294
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
295
+ try {
296
+ const response = await authedFetch(
297
+ this.config.url,
298
+ async () => ({
299
+ method: 'POST',
300
+ headers: await this.#headers(),
301
+ body: JSON.stringify({ jsonrpc: '2.0', id, method, params }),
302
+ signal: controller.signal,
303
+ }),
304
+ this.auth,
305
+ )
306
+ const session = response.headers.get('mcp-session-id')
307
+ if (session) this.sessionId = session
308
+ if (!response.ok) {
309
+ const text = await response.text().catch(() => '')
310
+ throw new Error(`MCP server answered ${response.status}${text ? `: ${text.slice(0, 200)}` : ''}`)
311
+ }
312
+ const type = response.headers.get('content-type') ?? ''
313
+ if (type.includes('text/event-stream')) return await readSse(response, id)
314
+ const message = await response.json()
315
+ if (message.error) throw new RpcError(message.error)
316
+ return message.result
317
+ } catch (error) {
318
+ if (controller.signal.aborted) throw new Error(`${method} timed out after ${Math.round(timeoutMs / 1000)}s.`)
319
+ throw error
320
+ } finally {
321
+ clearTimeout(timer)
322
+ }
323
+ }
324
+
325
+ async notify(method, params) {
326
+ const response = await authedFetch(
327
+ this.config.url,
328
+ async () => ({
329
+ method: 'POST',
330
+ headers: await this.#headers(),
331
+ body: JSON.stringify({ jsonrpc: '2.0', method, params }),
332
+ signal: AbortSignal.timeout(15_000),
333
+ }),
334
+ this.auth,
335
+ )
336
+ await response.body?.cancel().catch(() => undefined)
337
+ }
338
+
339
+ async close() {
340
+ if (!this.sessionId) return
341
+ await fetch(this.config.url, {
342
+ method: 'DELETE',
343
+ headers: await this.#headers(),
344
+ signal: AbortSignal.timeout(3000),
345
+ }).catch(() => undefined)
346
+ }
347
+ }
348
+
349
+ /**
350
+ * Legacy HTTP+SSE transport (protocol 2024-11-05): a long-lived GET stream carries every
351
+ * server message; the first `endpoint` event names the URL that requests are POSTed to.
352
+ */
353
+ class SseTransport {
354
+ constructor(config, { auth, onNotification } = {}) {
355
+ this.config = config
356
+ this.auth = auth
357
+ this.onNotification = onNotification
358
+ this.pending = new Map()
359
+ this.closed = false
360
+ this.controller = new AbortController()
361
+ }
362
+
363
+ async start() {
364
+ const base = checkRemoteUrl(this.config.url)
365
+ const response = await authedFetch(
366
+ this.config.url,
367
+ async () => ({
368
+ method: 'GET',
369
+ headers: await remoteHeaders(this.config, this.auth, { Accept: 'text/event-stream' }),
370
+ signal: this.controller.signal,
371
+ }),
372
+ this.auth,
373
+ )
374
+ if (!response.ok || !response.body) {
375
+ const text = await response.text().catch(() => '')
376
+ throw new Error(`MCP server answered ${response.status}${text ? `: ${text.slice(0, 200)}` : ''}`)
377
+ }
378
+ const endpoint = new Promise((resolve, reject) => {
379
+ this.resolveEndpoint = resolve
380
+ this.rejectEndpoint = reject
381
+ })
382
+ void this.#pump(response, base)
383
+ await withTimeout(endpoint, 30_000, 'SSE endpoint handshake')
384
+ }
385
+
386
+ async #pump(response, base) {
387
+ let failure = new Error('The MCP server closed the SSE stream.')
388
+ try {
389
+ for await (const { event, data } of sseEvents(response, this.controller.signal)) {
390
+ if (event === 'endpoint') {
391
+ const target = new URL(data.trim(), base)
392
+ if (target.origin !== base.origin) throw new Error('The SSE server pointed to a different host; refusing to follow it.')
393
+ this.endpoint = target.toString()
394
+ this.resolveEndpoint?.()
395
+ continue
396
+ }
397
+ let message
398
+ try {
399
+ message = JSON.parse(data)
400
+ } catch {
401
+ continue
402
+ }
403
+ this.#dispatch(message)
404
+ }
405
+ } catch (error) {
406
+ if (!this.controller.signal.aborted) failure = error instanceof Error ? error : failure
407
+ }
408
+ const wasClosed = this.closed
409
+ this.closed = true
410
+ this.rejectEndpoint?.(failure)
411
+ for (const entry of this.pending.values()) entry.reject(failure)
412
+ this.pending.clear()
413
+ if (!wasClosed) this.onExit?.(failure)
414
+ }
415
+
416
+ #dispatch(message) {
417
+ if (message && message.id !== undefined && (message.result !== undefined || message.error)) {
418
+ const entry = this.pending.get(message.id)
419
+ if (!entry) return
420
+ this.pending.delete(message.id)
421
+ if (message.error) entry.reject(new RpcError(message.error))
422
+ else entry.resolve(message.result)
423
+ return
424
+ }
425
+ if (message?.method && message.id !== undefined) {
426
+ const reply =
427
+ message.method === 'ping'
428
+ ? { jsonrpc: '2.0', id: message.id, result: {} }
429
+ : { jsonrpc: '2.0', id: message.id, error: { code: -32601, message: 'Method not supported by OpenSol.' } }
430
+ void this.#post(reply).catch(() => undefined)
431
+ return
432
+ }
433
+ if (message?.method) this.onNotification?.(message.method, message.params)
434
+ }
435
+
436
+ async #post(message) {
437
+ if (this.closed || !this.endpoint) throw new Error('The MCP server is not connected.')
438
+ const response = await authedFetch(
439
+ this.endpoint,
440
+ async () => ({
441
+ method: 'POST',
442
+ headers: await remoteHeaders(this.config, this.auth, { 'Content-Type': 'application/json' }),
443
+ body: JSON.stringify(message),
444
+ signal: AbortSignal.timeout(30_000),
445
+ }),
446
+ this.auth,
447
+ )
448
+ if (!response.ok) {
449
+ const text = await response.text().catch(() => '')
450
+ throw new Error(`MCP server answered ${response.status}${text ? `: ${text.slice(0, 200)}` : ''}`)
451
+ }
452
+ await response.body?.cancel().catch(() => undefined)
453
+ }
454
+
455
+ request(method, params, timeoutMs = REQUEST_TIMEOUT_MS) {
456
+ const id = ++requestCounter
457
+ const promise = new Promise((resolve, reject) => {
458
+ this.pending.set(id, { resolve, reject })
459
+ this.#post({ jsonrpc: '2.0', id, method, params }).catch((error) => {
460
+ this.pending.delete(id)
461
+ reject(error)
462
+ })
463
+ })
464
+ return withTimeout(promise, timeoutMs, method).catch((error) => {
465
+ this.pending.delete(id)
466
+ throw error
467
+ })
468
+ }
469
+
470
+ notify(method, params) {
471
+ return this.#post({ jsonrpc: '2.0', method, params })
472
+ }
473
+
474
+ async close() {
475
+ this.closed = true
476
+ this.controller.abort()
477
+ }
478
+ }
479
+
480
+ function slug(value, max) {
481
+ return (
482
+ String(value ?? '')
483
+ .toLowerCase()
484
+ .replace(/[^a-z0-9]+/g, '_')
485
+ .replace(/^_+|_+$/g, '')
486
+ .slice(0, max) || 'x'
487
+ )
488
+ }
489
+
490
+ /** Tool names must satisfy provider limits: ^[a-zA-Z0-9_-]{1,64}$. */
491
+ export function functionName(serverId, toolName) {
492
+ const prefix = `mcp__${slug(serverId, 18)}__`
493
+ const tool = String(toolName).replace(/[^a-zA-Z0-9_-]/g, '_')
494
+ return `${prefix}${tool}`.slice(0, 64)
495
+ }
496
+
497
+ function flattenContent(result) {
498
+ const parts = Array.isArray(result?.content) ? result.content : []
499
+ const lines = []
500
+ for (const part of parts) {
501
+ if (!part || typeof part !== 'object') continue
502
+ if (part.type === 'text' && typeof part.text === 'string') lines.push(part.text)
503
+ else if (part.type === 'image') lines.push(`[image ${part.mimeType ?? ''} omitted]`)
504
+ else if (part.type === 'audio') lines.push(`[audio ${part.mimeType ?? ''} omitted]`)
505
+ else if (part.type === 'resource' && part.resource) {
506
+ lines.push(typeof part.resource.text === 'string' ? part.resource.text : `[resource ${part.resource.uri ?? ''}]`)
507
+ } else if (part.type === 'resource_link') lines.push(`[resource ${part.uri ?? ''}]`)
508
+ }
509
+ if (lines.length === 0 && result?.structuredContent !== undefined) {
510
+ lines.push(JSON.stringify(result.structuredContent, null, 2))
511
+ }
512
+ const text = lines.join('\n')
513
+ return text.length > MAX_RESULT_CHARS ? `${text.slice(0, MAX_RESULT_CHARS)}\n… truncated` : text
514
+ }
515
+
516
+ function maskRecord(record) {
517
+ const out = {}
518
+ for (const key of Object.keys(record ?? {})) out[key] = MASK
519
+ return out
520
+ }
521
+
522
+ export function sanitizeServerConfig(input, existing) {
523
+ const raw = input && typeof input === 'object' ? input : {}
524
+ const name = typeof raw.name === 'string' ? raw.name.trim().slice(0, 60) : ''
525
+ if (!name) throw new HttpError(400, 'Give each MCP server a name.', 'bad_mcp')
526
+ const transport = raw.transport === 'http' || raw.transport === 'sse' ? raw.transport : 'stdio'
527
+ const id =
528
+ typeof raw.id === 'string' && /^[a-z0-9][a-z0-9-]{0,39}$/.test(raw.id)
529
+ ? raw.id
530
+ : slug(name, 30).replace(/_/g, '-') || 'server'
531
+
532
+ const keepSecret = (incoming, previous) => {
533
+ const out = {}
534
+ if (!incoming || typeof incoming !== 'object') return out
535
+ for (const [key, value] of Object.entries(incoming)) {
536
+ if (typeof value !== 'string' || key.length > 120) continue
537
+ out[key] = value === MASK ? previous?.[key] ?? '' : value.slice(0, 4000)
538
+ }
539
+ return out
540
+ }
541
+
542
+ const config = {
543
+ id,
544
+ name,
545
+ transport,
546
+ enabled: raw.enabled !== false,
547
+ approval: raw.approval === 'auto' ? 'auto' : 'ask',
548
+ disabledTools: Array.isArray(raw.disabledTools)
549
+ ? raw.disabledTools.filter((item) => typeof item === 'string').slice(0, 200)
550
+ : [],
551
+ }
552
+ if (transport === 'stdio') {
553
+ const command = typeof raw.command === 'string' ? raw.command.trim().slice(0, 500) : ''
554
+ if (!command) throw new HttpError(400, `"${name}" needs a command to start.`, 'bad_mcp')
555
+ config.command = command
556
+ config.args = Array.isArray(raw.args)
557
+ ? raw.args.filter((item) => typeof item === 'string').map((item) => item.slice(0, 1000)).slice(0, 60)
558
+ : []
559
+ const env = keepSecret(raw.env, existing?.env)
560
+ for (const key of Object.keys(env)) {
561
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) delete env[key]
562
+ }
563
+ config.env = env
564
+ } else {
565
+ const url = typeof raw.url === 'string' ? raw.url.trim().slice(0, 1000) : ''
566
+ if (!/^https?:\/\//i.test(url)) throw new HttpError(400, `"${name}" needs an http(s) URL.`, 'bad_mcp')
567
+ config.url = url
568
+ const headers = keepSecret(raw.headers, existing?.headers)
569
+ for (const key of Object.keys(headers)) {
570
+ if (!/^[A-Za-z0-9-]{1,80}$/.test(key)) delete headers[key]
571
+ }
572
+ config.headers = headers
573
+ const oauth = raw.oauth && typeof raw.oauth === 'object' && raw.oauth.enabled ? raw.oauth : null
574
+ if (oauth) {
575
+ const text = (value, max) => (typeof value === 'string' ? value.trim().slice(0, max) : '')
576
+ config.oauth = {
577
+ enabled: true,
578
+ clientId: text(oauth.clientId, 300),
579
+ clientSecret: oauth.clientSecret === MASK ? existing?.oauth?.clientSecret ?? '' : text(oauth.clientSecret, 600),
580
+ scope: text(oauth.scope, 400),
581
+ }
582
+ }
583
+ }
584
+ return config
585
+ }
586
+
587
+ export class McpManager {
588
+ constructor() {
589
+ this.configs = new Map()
590
+ this.runtime = new Map()
591
+ this.getCwd = async () => os.homedir()
592
+ this.pendingAuth = new PendingAuthorizations()
593
+ this.refreshing = new Map()
594
+ // OAuth client + tokens per server. The daemon swaps in the encrypted secrets file.
595
+ const memory = new Map()
596
+ this.authStore = {
597
+ get: async (id) => memory.get(id) ?? null,
598
+ set: async (id, value) => void memory.set(id, value),
599
+ delete: async (id) => void memory.delete(id),
600
+ }
601
+ }
602
+
603
+ /** Auth hooks handed to a remote transport; null when the server does not use OAuth. */
604
+ #authHooks(config) {
605
+ if (!config.oauth?.enabled) return null
606
+ const id = config.id
607
+ const refresh = () => {
608
+ const running = this.refreshing.get(id)
609
+ if (running) return running
610
+ const task = (async () => {
611
+ const entry = await this.authStore.get(id)
612
+ if (!entry?.tokens?.refreshToken || !entry.metadata || !entry.client) return false
613
+ try {
614
+ const tokens = await refreshTokens({ metadata: entry.metadata, client: entry.client, tokens: entry.tokens, resource: entry.metadata.resource })
615
+ await this.authStore.set(id, { ...entry, tokens })
616
+ return true
617
+ } catch (error) {
618
+ // A rejected refresh token means the grant is gone; anything else may be transient.
619
+ if (error?.oauthError === 'invalid_grant') await this.authStore.set(id, { ...entry, tokens: null })
620
+ return false
621
+ }
622
+ })().finally(() => this.refreshing.delete(id))
623
+ this.refreshing.set(id, task)
624
+ return task
625
+ }
626
+ return {
627
+ refresh,
628
+ header: async () => {
629
+ let entry = await this.authStore.get(id)
630
+ if (!entry?.tokens) return ''
631
+ if (!tokenIsFresh(entry.tokens)) {
632
+ if (!(await refresh())) return ''
633
+ entry = await this.authStore.get(id)
634
+ }
635
+ return entry?.tokens?.accessToken ? `Bearer ${entry.tokens.accessToken}` : ''
636
+ },
637
+ }
638
+ }
639
+
640
+ /** Step 1 of sign-in: returns the URL the person must open to approve access. */
641
+ async startAuth(id, redirectUri) {
642
+ const config = this.configs.get(id)
643
+ if (!config) throw new HttpError(404, 'Unknown MCP server.', 'mcp_missing')
644
+ if (!config.oauth?.enabled) throw new HttpError(400, 'OAuth is not turned on for this server.', 'mcp_no_oauth')
645
+ const state = this.runtime.get(id)
646
+ try {
647
+ const stored = await this.authStore.get(id)
648
+ const metadata = await discover(config.url, state?.wwwAuthenticate ?? '')
649
+ let client
650
+ if (config.oauth.clientId) {
651
+ client = { clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret || '' }
652
+ } else if (stored?.client?.redirectUri === redirectUri && stored.client.issuer === metadata.issuer) {
653
+ client = stored.client
654
+ } else {
655
+ client = { ...(await registerClient(metadata, redirectUri, 'OpenSol')), redirectUri, issuer: metadata.issuer }
656
+ }
657
+ const { verifier, challenge } = createPkce()
658
+ const resource = metadata.resource || config.url
659
+ const key = this.pendingAuth.create({ id, verifier, redirectUri, metadata, client, resource })
660
+ return {
661
+ url: buildAuthorizationUrl({
662
+ metadata,
663
+ clientId: client.clientId,
664
+ redirectUri,
665
+ state: key,
666
+ challenge,
667
+ scope: config.oauth.scope,
668
+ resource,
669
+ }),
670
+ }
671
+ } catch (error) {
672
+ throw new HttpError(502, error instanceof Error ? error.message : 'Could not start sign-in.', 'mcp_auth_failed')
673
+ }
674
+ }
675
+
676
+ /** Step 2: the authorization server redirected back with a code. */
677
+ async finishAuth(stateKey, code) {
678
+ const pending = this.pendingAuth.take(stateKey)
679
+ if (!pending) throw new HttpError(400, 'This sign-in link expired or was already used. Start again from the MCP panel.', 'mcp_auth_state')
680
+ const config = this.configs.get(pending.id)
681
+ if (!config) throw new HttpError(404, 'That MCP server was removed.', 'mcp_missing')
682
+ let tokens
683
+ try {
684
+ tokens = await exchangeCode({
685
+ metadata: pending.metadata,
686
+ client: pending.client,
687
+ code,
688
+ verifier: pending.verifier,
689
+ redirectUri: pending.redirectUri,
690
+ resource: pending.resource,
691
+ })
692
+ } catch (error) {
693
+ throw new HttpError(502, error instanceof Error ? error.message : 'Could not finish sign-in.', 'mcp_auth_failed')
694
+ }
695
+ await this.authStore.set(pending.id, { metadata: pending.metadata, client: pending.client, tokens })
696
+ await this.connect(pending.id)
697
+ return config
698
+ }
699
+
700
+ async signOut(id) {
701
+ await this.authStore.delete(id)
702
+ await this.disconnect(id)
703
+ const config = this.configs.get(id)
704
+ if (config?.enabled && config.oauth?.enabled) {
705
+ this.runtime.set(id, { status: 'needs_auth', error: '', tools: [], authorized: false })
706
+ }
707
+ return config ? this.view(config) : null
708
+ }
709
+
710
+ async setConfigs(configs) {
711
+ const next = new Map(configs.map((config) => [config.id, config]))
712
+ const stale = []
713
+ for (const [id, previous] of this.configs) {
714
+ const config = next.get(id)
715
+ if (!config || JSON.stringify(config) !== JSON.stringify(previous)) stale.push(id)
716
+ }
717
+ await Promise.all(stale.map((id) => this.disconnect(id)))
718
+ this.configs = next
719
+ for (const id of [...this.runtime.keys()]) if (!next.has(id)) this.runtime.delete(id)
720
+ }
721
+
722
+ list() {
723
+ return [...this.configs.values()].map((config) => this.view(config))
724
+ }
725
+
726
+ view(config) {
727
+ const state = this.runtime.get(config.id) ?? {}
728
+ const base = {
729
+ id: config.id,
730
+ name: config.name,
731
+ transport: config.transport,
732
+ enabled: config.enabled,
733
+ approval: config.approval,
734
+ disabledTools: config.disabledTools ?? [],
735
+ status: state.status ?? (config.enabled ? 'disconnected' : 'disabled'),
736
+ error: state.error ?? '',
737
+ serverInfo: state.serverInfo ?? null,
738
+ connectedAt: state.connectedAt ?? null,
739
+ oauth: config.oauth?.enabled
740
+ ? {
741
+ enabled: true,
742
+ clientId: config.oauth.clientId,
743
+ clientSecret: config.oauth.clientSecret ? MASK : '',
744
+ scope: config.oauth.scope,
745
+ authorized: state.authorized === true,
746
+ }
747
+ : null,
748
+ tools: (state.tools ?? []).map((tool) => ({
749
+ name: tool.name,
750
+ fn: functionName(config.id, tool.name),
751
+ description: typeof tool.description === 'string' ? tool.description.slice(0, 1200) : '',
752
+ inputSchema:
753
+ tool.inputSchema && typeof tool.inputSchema === 'object'
754
+ ? tool.inputSchema
755
+ : { type: 'object', properties: {} },
756
+ readOnly: tool.annotations?.readOnlyHint === true,
757
+ })),
758
+ }
759
+ if (config.transport === 'stdio') {
760
+ return { ...base, command: config.command, args: config.args ?? [], env: maskRecord(config.env) }
761
+ }
762
+ return { ...base, url: config.url, headers: maskRecord(config.headers) }
763
+ }
764
+
765
+ async connect(id) {
766
+ const config = this.configs.get(id)
767
+ if (!config) throw new HttpError(404, 'Unknown MCP server.', 'mcp_missing')
768
+ await this.disconnect(id)
769
+ const state = { status: 'connecting', error: '', tools: [] }
770
+ this.runtime.set(id, state)
771
+ try {
772
+ const onNotification = (method) => {
773
+ if (method === 'notifications/tools/list_changed') void this.refresh(id).catch(() => undefined)
774
+ }
775
+ const auth = config.transport === 'stdio' ? null : this.#authHooks(config)
776
+ const transport =
777
+ config.transport === 'http'
778
+ ? new HttpTransport(config, { auth })
779
+ : config.transport === 'sse'
780
+ ? new SseTransport(config, { auth, onNotification })
781
+ : new StdioTransport(config, { cwd: await this.getCwd(), onNotification })
782
+ state.transport = transport
783
+ state.authorized = Boolean(auth && (await this.authStore.get(id))?.tokens)
784
+ await transport.start()
785
+ if (config.transport !== 'http') {
786
+ transport.onExit = (error) => {
787
+ if (this.runtime.get(id) === state && state.status === 'connected') {
788
+ state.status = 'error'
789
+ state.error = error.message
790
+ }
791
+ }
792
+ }
793
+ const init = await withTimeout(
794
+ transport.request('initialize', {
795
+ protocolVersion: PROTOCOL_VERSION,
796
+ capabilities: {},
797
+ clientInfo: { name: 'OpenSol', version: '3.1.0' },
798
+ }),
799
+ CONNECT_TIMEOUT_MS,
800
+ 'initialize',
801
+ )
802
+ await transport.notify('notifications/initialized')
803
+ state.serverInfo = init?.serverInfo ?? null
804
+ state.tools = await this.#listTools(transport)
805
+ state.status = 'connected'
806
+ state.connectedAt = Date.now()
807
+ state.authorized = Boolean(auth && (await this.authStore.get(id))?.tokens)
808
+ } catch (error) {
809
+ if (error instanceof AuthRequiredError) {
810
+ state.authorized = false
811
+ if (config.oauth?.enabled) {
812
+ state.status = 'needs_auth'
813
+ state.error = ''
814
+ state.wwwAuthenticate = error.wwwAuthenticate
815
+ } else {
816
+ state.status = 'error'
817
+ state.error = 'The server answered 401 (sign-in required). Add credentials under Headers, or turn on OAuth.'
818
+ }
819
+ } else {
820
+ state.status = 'error'
821
+ state.error = error instanceof Error ? error.message : 'Could not connect.'
822
+ }
823
+ await state.transport?.close().catch(() => undefined)
824
+ state.transport = null
825
+ }
826
+ return this.view(config)
827
+ }
828
+
829
+ async #listTools(transport) {
830
+ const tools = []
831
+ let cursor
832
+ for (let page = 0; page < 10; page += 1) {
833
+ const result = await transport.request('tools/list', cursor ? { cursor } : {}, 30_000)
834
+ if (Array.isArray(result?.tools)) tools.push(...result.tools.filter((tool) => tool && typeof tool.name === 'string'))
835
+ cursor = result?.nextCursor
836
+ if (!cursor) break
837
+ }
838
+ return tools
839
+ }
840
+
841
+ async refresh(id) {
842
+ const config = this.configs.get(id)
843
+ const state = this.runtime.get(id)
844
+ if (!config) throw new HttpError(404, 'Unknown MCP server.', 'mcp_missing')
845
+ if (!state?.transport || state.status !== 'connected') return this.connect(id)
846
+ state.tools = await this.#listTools(state.transport)
847
+ return this.view(config)
848
+ }
849
+
850
+ async disconnect(id) {
851
+ const state = this.runtime.get(id)
852
+ if (state?.transport) await state.transport.close().catch(() => undefined)
853
+ const config = this.configs.get(id)
854
+ this.runtime.set(id, { status: config?.enabled === false ? 'disabled' : 'disconnected', error: '', tools: [] })
855
+ return config ? this.view(config) : null
856
+ }
857
+
858
+ async connectAll() {
859
+ await Promise.all(
860
+ [...this.configs.values()].filter((config) => config.enabled).map((config) => this.connect(config.id)),
861
+ )
862
+ }
863
+
864
+ async shutdown() {
865
+ await Promise.all([...this.runtime.keys()].map((id) => this.disconnect(id)))
866
+ }
867
+
868
+ async callTool(id, toolName, args) {
869
+ const config = this.configs.get(id)
870
+ const state = this.runtime.get(id)
871
+ if (!config) throw new HttpError(404, 'Unknown MCP server.', 'mcp_missing')
872
+ if (!state?.transport || state.status !== 'connected') {
873
+ throw new HttpError(409, `${config.name} is not connected. Reconnect it in the MCP panel.`, 'mcp_disconnected')
874
+ }
875
+ if ((config.disabledTools ?? []).includes(toolName)) {
876
+ throw new HttpError(403, `${toolName} is turned off for ${config.name}.`, 'mcp_tool_disabled')
877
+ }
878
+ try {
879
+ const result = await state.transport.request('tools/call', {
880
+ name: toolName,
881
+ arguments: args && typeof args === 'object' ? args : {},
882
+ })
883
+ return { text: flattenContent(result), isError: result?.isError === true }
884
+ } catch (error) {
885
+ if (error instanceof RpcError) return { text: error.message, isError: true }
886
+ if (error instanceof AuthRequiredError) {
887
+ state.status = 'needs_auth'
888
+ state.authorized = false
889
+ state.wwwAuthenticate = error.wwwAuthenticate
890
+ await state.transport?.close().catch(() => undefined)
891
+ state.transport = null
892
+ throw new HttpError(401, `${config.name} needs you to sign in again. Open the MCP panel and press Authorize.`, 'mcp_auth')
893
+ }
894
+ throw new HttpError(502, error instanceof Error ? error.message : 'The MCP call failed.', 'mcp_failed')
895
+ }
896
+ }
897
+ }
898
+
899
+ export const mcp = new McpManager()