@bibobonking/opensol 3.0.3 → 3.1.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 (71) hide show
  1. package/.github/workflows/build.yml +60 -8
  2. package/CHANGELOG.md +18 -0
  3. package/desktop/README.md +39 -0
  4. package/desktop/loading/index.html +36 -0
  5. package/desktop/src-tauri/Cargo.lock +4301 -0
  6. package/desktop/src-tauri/Cargo.toml +23 -0
  7. package/desktop/src-tauri/binaries/.gitkeep +0 -0
  8. package/desktop/src-tauri/build.rs +3 -0
  9. package/desktop/src-tauri/capabilities/default.json +7 -0
  10. package/desktop/src-tauri/icons/128x128.png +0 -0
  11. package/desktop/src-tauri/icons/128x128@2x.png +0 -0
  12. package/desktop/src-tauri/icons/32x32.png +0 -0
  13. package/desktop/src-tauri/icons/64x64.png +0 -0
  14. package/desktop/src-tauri/icons/Square107x107Logo.png +0 -0
  15. package/desktop/src-tauri/icons/Square142x142Logo.png +0 -0
  16. package/desktop/src-tauri/icons/Square150x150Logo.png +0 -0
  17. package/desktop/src-tauri/icons/Square284x284Logo.png +0 -0
  18. package/desktop/src-tauri/icons/Square30x30Logo.png +0 -0
  19. package/desktop/src-tauri/icons/Square310x310Logo.png +0 -0
  20. package/desktop/src-tauri/icons/Square44x44Logo.png +0 -0
  21. package/desktop/src-tauri/icons/Square71x71Logo.png +0 -0
  22. package/desktop/src-tauri/icons/Square89x89Logo.png +0 -0
  23. package/desktop/src-tauri/icons/StoreLogo.png +0 -0
  24. package/desktop/src-tauri/icons/icon.icns +0 -0
  25. package/desktop/src-tauri/icons/icon.ico +0 -0
  26. package/desktop/src-tauri/icons/icon.png +0 -0
  27. package/desktop/src-tauri/src/lib.rs +210 -0
  28. package/desktop/src-tauri/src/main.rs +6 -0
  29. package/desktop/src-tauri/tauri.conf.json +48 -0
  30. package/dist/assets/index-DdI1_iPD.css +1 -0
  31. package/dist/assets/index-egG5Bgfo.js +312 -0
  32. package/dist/index.html +2 -2
  33. package/package.json +2 -1
  34. package/server/daemon.mjs +64 -2
  35. package/server/lib/mcp.mjs +532 -0
  36. package/server/lib/mcp.test.mjs +46 -0
  37. package/server/lib/store.mjs +22 -0
  38. package/server/lib/tools.mjs +59 -1
  39. package/src/agent/tools.ts +6 -5
  40. package/src/components/Composer.tsx +22 -38
  41. package/src/components/DiffView.tsx +98 -0
  42. package/src/components/McpTab.tsx +337 -0
  43. package/src/components/MessageItem.tsx +62 -13
  44. package/src/components/ModelDeck.tsx +652 -0
  45. package/src/components/SessionsTab.tsx +206 -0
  46. package/src/components/SettingsModal.tsx +106 -69
  47. package/src/components/Sidebar.tsx +78 -24
  48. package/src/components/ToolCallCard.tsx +29 -7
  49. package/src/components/WorkspacePanel.tsx +16 -1
  50. package/src/core/kernel.ts +2 -0
  51. package/src/core/mcp-tools.ts +63 -0
  52. package/src/core/plugins/tools/fs.ts +24 -3
  53. package/src/core/services/agent-loop.ts +3 -1
  54. package/src/core/services/approval.ts +2 -1
  55. package/src/core/services/titles.ts +135 -0
  56. package/src/core/services/tools.ts +11 -8
  57. package/src/core/types.ts +4 -0
  58. package/src/index.css +97 -1
  59. package/src/lib/daemon.ts +29 -0
  60. package/src/lib/diff.ts +62 -0
  61. package/src/lib/permissions.ts +6 -6
  62. package/src/store/chats.ts +9 -1
  63. package/src/store/mcp.ts +67 -0
  64. package/src/store/settings.ts +9 -6
  65. package/src/store/ui.ts +13 -0
  66. package/src/store/workspace.ts +26 -0
  67. package/src/types.ts +51 -2
  68. package/tsconfig.tsbuildinfo +1 -1
  69. package/dist/assets/index-BmN7pBji.css +0 -1
  70. package/dist/assets/index-DgNDjkOz.js +0 -301
  71. package/src/components/ModelPicker.tsx +0 -487
package/dist/index.html CHANGED
@@ -31,8 +31,8 @@
31
31
  if (DARK.includes(theme)) root.classList.add('dark')
32
32
  } catch {}
33
33
  </script>
34
- <script type="module" crossorigin src="./assets/index-DgNDjkOz.js"></script>
35
- <link rel="stylesheet" crossorigin href="./assets/index-BmN7pBji.css">
34
+ <script type="module" crossorigin src="./assets/index-egG5Bgfo.js"></script>
35
+ <link rel="stylesheet" crossorigin href="./assets/index-DdI1_iPD.css">
36
36
  </head>
37
37
  <body>
38
38
  <div id="root"><div class="splash" id="boot-splash" aria-hidden="true">
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@bibobonking/opensol",
3
3
  "private": false,
4
- "version": "3.0.3",
4
+ "version": "3.1.0",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "opensol": "./server/opensol.mjs"
@@ -9,6 +9,7 @@
9
9
  "scripts": {
10
10
  "dev": "node server/opensol.mjs start && vite",
11
11
  "build": "tsc -b && vite build",
12
+ "artifacts": "node scripts/pull-artifacts.mjs",
12
13
  "preview": "vite preview",
13
14
  "typecheck": "tsc -b --pretty",
14
15
  "test": "node --test",
package/server/daemon.mjs CHANGED
@@ -22,12 +22,15 @@ import {
22
22
  DATA_DIR,
23
23
  dataPaths,
24
24
  forgetWorkspace,
25
+ getMcpServers,
25
26
  loadSecretsSafe,
26
27
  loadState,
27
28
  migrateLegacy,
28
29
  resetAll,
30
+ removeWorkspace,
29
31
  resetSecrets,
30
32
  saveChats,
33
+ saveMcpServers,
31
34
  saveSettings,
32
35
  saveState,
33
36
  secretSummary,
@@ -75,10 +78,11 @@ import {
75
78
  setPluginSafe,
76
79
  uninstallPlugin,
77
80
  } from './lib/plugins.mjs'
81
+ import { mcp, sanitizeServerConfig } from './lib/mcp.mjs'
78
82
 
79
83
  const HERE = path.dirname(fileURLToPath(import.meta.url))
80
84
  const DIST = path.resolve(HERE, '..', 'dist')
81
- const VERSION = '1.0.0'
85
+ const VERSION = '3.1.0'
82
86
  const SESSION_COOKIE = 'maxcode_session'
83
87
  const CSRF_HEADER = 'x-maxcode-csrf'
84
88
  const SESSION_TTL = 14 * 24 * 60 * 60 * 1000
@@ -220,6 +224,12 @@ function sanitizeSettings(patch, current = {}) {
220
224
  // Plugin themes are named by their plugin, so accept any bounded theme slug.
221
225
  if (typeof source.theme === 'string' && /^[a-z0-9-]{1,64}$/.test(source.theme)) next.theme = source.theme
222
226
  if (source.mode === 'build' || source.mode === 'plan') next.mode = source.mode
227
+ if (typeof source.aiSessionTitles === 'boolean') next.aiSessionTitles = source.aiSessionTitles
228
+ if (typeof source.autoCollapseWork === 'boolean') next.autoCollapseWork = source.autoCollapseWork
229
+ if (source.workDetail === 'concise' || source.workDetail === 'detailed') next.workDetail = source.workDetail
230
+ if (typeof source.chatFontSize === 'number' && Number.isFinite(source.chatFontSize)) {
231
+ next.chatFontSize = Math.max(12, Math.min(20, Math.round(source.chatFontSize)))
232
+ }
223
233
 
224
234
  if (source.providers && typeof source.providers === 'object') {
225
235
  const providers = { ...(current.providers ?? {}) }
@@ -747,13 +757,61 @@ async function route(req, res) {
747
757
  return
748
758
  }
749
759
  if (method === 'DELETE') {
750
- await forgetWorkspace()
760
+ const target = url.searchParams.get('path')
761
+ if (target) await removeWorkspace(target)
762
+ else await forgetWorkspace()
751
763
  json(res, 200, await workspacePayload())
752
764
  return
753
765
  }
754
766
  throw new HttpError(405, 'Method not allowed.', 'method_not_allowed')
755
767
  }
756
768
 
769
+ if (pathname === '/api/mcp' && method === 'GET') {
770
+ json(res, 200, { servers: mcp.list() })
771
+ return
772
+ }
773
+
774
+ if (pathname === '/api/mcp' && method === 'PUT') {
775
+ const body = await readJson(req, 512 * 1024)
776
+ const incoming = Array.isArray(body.servers) ? body.servers.slice(0, 30) : []
777
+ const previous = new Map((await getMcpServers()).map((item) => [item.id, item]))
778
+ const seen = new Set()
779
+ const configs = incoming.map((item) => {
780
+ let config = sanitizeServerConfig(item, previous.get(item?.id))
781
+ let suffix = 2
782
+ const base = config.id
783
+ while (seen.has(config.id)) config = { ...config, id: `${base}-${suffix++}` }
784
+ seen.add(config.id)
785
+ return config
786
+ })
787
+ await saveMcpServers(configs)
788
+ await mcp.setConfigs(configs)
789
+ for (const config of configs) {
790
+ const runtime = mcp.runtime.get(config.id)
791
+ if (config.enabled && (!runtime || runtime.status === 'disconnected')) void mcp.connect(config.id)
792
+ if (!config.enabled && runtime?.status === 'connected') await mcp.disconnect(config.id)
793
+ }
794
+ json(res, 200, { servers: mcp.list() })
795
+ return
796
+ }
797
+
798
+ const mcpRoute = /^\/api\/mcp\/([a-z0-9][a-z0-9-]{0,39})\/(connect|disconnect|refresh)$/.exec(pathname)
799
+ if (mcpRoute && method === 'POST') {
800
+ const [, id, action] = mcpRoute
801
+ if (action === 'connect') await mcp.connect(id)
802
+ else if (action === 'disconnect') await mcp.disconnect(id)
803
+ else await mcp.refresh(id)
804
+ json(res, 200, { servers: mcp.list() })
805
+ return
806
+ }
807
+
808
+ if (pathname === '/api/mcp/call' && method === 'POST') {
809
+ const body = await readJson(req, 4 * 1024 * 1024)
810
+ const result = await mcp.callTool(String(body.server ?? ''), String(body.tool ?? ''), body.args ?? {})
811
+ json(res, 200, { result })
812
+ return
813
+ }
814
+
757
815
  if (pathname === '/api/workspaces/browse') {
758
816
  if (method === 'GET') {
759
817
  json(res, 200, await browseDirectories(url.searchParams.get('path') ?? ''))
@@ -988,6 +1046,9 @@ async function findPort(start) {
988
1046
 
989
1047
  export async function startServer({ port: requested = 4174, open = false } = {}) {
990
1048
  await mkdir(DATA_DIR, { recursive: true, mode: 0o700 })
1049
+ mcp.getCwd = async () => (await workspaceRoot().catch(() => '')) || os.homedir()
1050
+ await mcp.setConfigs(await getMcpServers())
1051
+ void mcp.connectAll().catch(() => undefined)
991
1052
  port = requested === 0 ? await findPort(4174) : requested
992
1053
  const server = http.createServer((req, res) => {
993
1054
  handler(req, res)
@@ -1008,6 +1069,7 @@ export async function startServer({ port: requested = 4174, open = false } = {})
1008
1069
  const shutdown = async (signal) => {
1009
1070
  console.log(`[opensol] ${signal} received, shutting down.`)
1010
1071
  server.close()
1072
+ await mcp.shutdown().catch(() => undefined)
1011
1073
  await rm(pidFile, { force: true }).catch(() => undefined)
1012
1074
  process.exit(0)
1013
1075
  }
@@ -0,0 +1,532 @@
1
+ // Minimal Model Context Protocol client (stdio + Streamable HTTP), 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
+
7
+ const PROTOCOL_VERSION = '2025-03-26'
8
+ const CONNECT_TIMEOUT_MS = 60_000
9
+ const REQUEST_TIMEOUT_MS = 120_000
10
+ const MAX_RESULT_CHARS = 200_000
11
+ const MASK = '••••••••'
12
+
13
+ let requestCounter = 0
14
+
15
+ function withTimeout(promise, ms, label) {
16
+ let timer
17
+ const timeout = new Promise((_, reject) => {
18
+ timer = setTimeout(() => reject(new Error(`${label} timed out after ${Math.round(ms / 1000)}s.`)), ms)
19
+ })
20
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer))
21
+ }
22
+
23
+ class RpcError extends Error {
24
+ constructor(error) {
25
+ super(error?.message || 'MCP request failed.')
26
+ this.code = error?.code
27
+ }
28
+ }
29
+
30
+ /** Newline-delimited JSON-RPC over a child process. */
31
+ class StdioTransport {
32
+ constructor(config, { onNotification, cwd }) {
33
+ this.config = config
34
+ this.onNotification = onNotification
35
+ this.cwd = cwd
36
+ this.pending = new Map()
37
+ this.buffer = ''
38
+ this.stderr = ''
39
+ this.closed = false
40
+ }
41
+
42
+ async start() {
43
+ const win = process.platform === 'win32'
44
+ const env = { ...process.env, ...(this.config.env ?? {}) }
45
+ this.child = spawn(this.config.command, this.config.args ?? [], {
46
+ cwd: this.cwd,
47
+ env,
48
+ shell: win,
49
+ stdio: ['pipe', 'pipe', 'pipe'],
50
+ windowsHide: true,
51
+ })
52
+ this.child.stdout.setEncoding('utf8')
53
+ this.child.stdout.on('data', (chunk) => this.#onData(chunk))
54
+ this.child.stderr.setEncoding('utf8')
55
+ this.child.stderr.on('data', (chunk) => {
56
+ this.stderr = (this.stderr + chunk).slice(-4000)
57
+ })
58
+ this.child.stdin.on('error', () => undefined)
59
+ await new Promise((resolve, reject) => {
60
+ this.child.once('spawn', resolve)
61
+ this.child.once('error', (error) => reject(new Error(`Could not start "${this.config.command}": ${error.message}`)))
62
+ })
63
+ this.child.on('exit', (code) => {
64
+ this.closed = true
65
+ const detail = this.stderr.trim().split('\n').slice(-3).join(' ').slice(0, 300)
66
+ const error = new Error(`The MCP server exited (code ${code ?? 'signal'}).${detail ? ` ${detail}` : ''}`)
67
+ for (const entry of this.pending.values()) entry.reject(error)
68
+ this.pending.clear()
69
+ this.onExit?.(error)
70
+ })
71
+ }
72
+
73
+ #onData(chunk) {
74
+ this.buffer += chunk
75
+ let index
76
+ while ((index = this.buffer.indexOf('\n')) >= 0) {
77
+ const line = this.buffer.slice(0, index).trim()
78
+ this.buffer = this.buffer.slice(index + 1)
79
+ if (!line) continue
80
+ let message
81
+ try {
82
+ message = JSON.parse(line)
83
+ } catch {
84
+ continue
85
+ }
86
+ this.#dispatch(message)
87
+ }
88
+ }
89
+
90
+ #dispatch(message) {
91
+ if (message && message.id !== undefined && (message.result !== undefined || message.error)) {
92
+ const entry = this.pending.get(message.id)
93
+ if (!entry) return
94
+ this.pending.delete(message.id)
95
+ if (message.error) entry.reject(new RpcError(message.error))
96
+ else entry.resolve(message.result)
97
+ return
98
+ }
99
+ if (message?.method && message.id !== undefined) {
100
+ // Server-initiated requests: answer pings, refuse everything else politely.
101
+ const reply =
102
+ message.method === 'ping'
103
+ ? { jsonrpc: '2.0', id: message.id, result: {} }
104
+ : { jsonrpc: '2.0', id: message.id, error: { code: -32601, message: 'Method not supported by OpenSol.' } }
105
+ this.#write(reply)
106
+ return
107
+ }
108
+ if (message?.method) this.onNotification?.(message.method, message.params)
109
+ }
110
+
111
+ #write(message) {
112
+ if (this.closed || !this.child?.stdin?.writable) throw new Error('The MCP server is not running.')
113
+ this.child.stdin.write(`${JSON.stringify(message)}\n`)
114
+ }
115
+
116
+ request(method, params, timeoutMs = REQUEST_TIMEOUT_MS) {
117
+ const id = ++requestCounter
118
+ const promise = new Promise((resolve, reject) => {
119
+ this.pending.set(id, { resolve, reject })
120
+ try {
121
+ this.#write({ jsonrpc: '2.0', id, method, params })
122
+ } catch (error) {
123
+ this.pending.delete(id)
124
+ reject(error)
125
+ }
126
+ })
127
+ return withTimeout(promise, timeoutMs, method).catch((error) => {
128
+ this.pending.delete(id)
129
+ throw error
130
+ })
131
+ }
132
+
133
+ notify(method, params) {
134
+ this.#write({ jsonrpc: '2.0', method, params })
135
+ }
136
+
137
+ async close() {
138
+ this.closed = true
139
+ const child = this.child
140
+ if (!child || child.exitCode !== null) return
141
+ try {
142
+ child.stdin.end()
143
+ } catch {
144
+ // Already closed.
145
+ }
146
+ await new Promise((resolve) => {
147
+ const timer = setTimeout(() => {
148
+ child.kill('SIGKILL')
149
+ resolve()
150
+ }, 1500)
151
+ child.once('exit', () => {
152
+ clearTimeout(timer)
153
+ resolve()
154
+ })
155
+ child.kill('SIGTERM')
156
+ })
157
+ }
158
+ }
159
+
160
+ async function readSse(response, wantedId) {
161
+ const reader = response.body.getReader()
162
+ const decoder = new TextDecoder()
163
+ let buffer = ''
164
+ for (;;) {
165
+ const { value, done } = await reader.read()
166
+ if (done) break
167
+ buffer += decoder.decode(value, { stream: true })
168
+ let boundary
169
+ while ((boundary = buffer.search(/\r?\n\r?\n/)) >= 0) {
170
+ const block = buffer.slice(0, boundary)
171
+ buffer = buffer.slice(boundary).replace(/^\r?\n\r?\n/, '')
172
+ const data = block
173
+ .split(/\r?\n/)
174
+ .filter((line) => line.startsWith('data:'))
175
+ .map((line) => line.slice(5).replace(/^ /, ''))
176
+ .join('\n')
177
+ if (!data) continue
178
+ let message
179
+ try {
180
+ message = JSON.parse(data)
181
+ } catch {
182
+ continue
183
+ }
184
+ if (message.id === wantedId && (message.result !== undefined || message.error)) {
185
+ reader.cancel().catch(() => undefined)
186
+ if (message.error) throw new RpcError(message.error)
187
+ return message.result
188
+ }
189
+ }
190
+ }
191
+ throw new Error('The MCP server closed the stream without answering.')
192
+ }
193
+
194
+ /** Streamable HTTP transport: one POST per message, JSON or SSE replies. */
195
+ class HttpTransport {
196
+ constructor(config) {
197
+ this.config = config
198
+ this.sessionId = ''
199
+ }
200
+
201
+ async start() {
202
+ let parsed
203
+ try {
204
+ parsed = new URL(this.config.url)
205
+ } catch {
206
+ throw new Error('The MCP server URL is not valid.')
207
+ }
208
+ if (!['http:', 'https:'].includes(parsed.protocol)) throw new Error('MCP servers must use http or https.')
209
+ }
210
+
211
+ #headers() {
212
+ return {
213
+ 'Content-Type': 'application/json',
214
+ Accept: 'application/json, text/event-stream',
215
+ 'MCP-Protocol-Version': PROTOCOL_VERSION,
216
+ ...(this.sessionId ? { 'Mcp-Session-Id': this.sessionId } : {}),
217
+ ...(this.config.headers ?? {}),
218
+ }
219
+ }
220
+
221
+ async request(method, params, timeoutMs = REQUEST_TIMEOUT_MS) {
222
+ const id = ++requestCounter
223
+ const controller = new AbortController()
224
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
225
+ try {
226
+ const response = await fetch(this.config.url, {
227
+ method: 'POST',
228
+ headers: this.#headers(),
229
+ body: JSON.stringify({ jsonrpc: '2.0', id, method, params }),
230
+ signal: controller.signal,
231
+ })
232
+ const session = response.headers.get('mcp-session-id')
233
+ if (session) this.sessionId = session
234
+ if (!response.ok) {
235
+ const text = await response.text().catch(() => '')
236
+ throw new Error(`MCP server answered ${response.status}${text ? `: ${text.slice(0, 200)}` : ''}`)
237
+ }
238
+ const type = response.headers.get('content-type') ?? ''
239
+ if (type.includes('text/event-stream')) return await readSse(response, id)
240
+ const message = await response.json()
241
+ if (message.error) throw new RpcError(message.error)
242
+ return message.result
243
+ } catch (error) {
244
+ if (controller.signal.aborted) throw new Error(`${method} timed out after ${Math.round(timeoutMs / 1000)}s.`)
245
+ throw error
246
+ } finally {
247
+ clearTimeout(timer)
248
+ }
249
+ }
250
+
251
+ async notify(method, params) {
252
+ const response = await fetch(this.config.url, {
253
+ method: 'POST',
254
+ headers: this.#headers(),
255
+ body: JSON.stringify({ jsonrpc: '2.0', method, params }),
256
+ signal: AbortSignal.timeout(15_000),
257
+ })
258
+ await response.body?.cancel().catch(() => undefined)
259
+ }
260
+
261
+ async close() {
262
+ if (!this.sessionId) return
263
+ await fetch(this.config.url, {
264
+ method: 'DELETE',
265
+ headers: this.#headers(),
266
+ signal: AbortSignal.timeout(3000),
267
+ }).catch(() => undefined)
268
+ }
269
+ }
270
+
271
+ function slug(value, max) {
272
+ return (
273
+ String(value ?? '')
274
+ .toLowerCase()
275
+ .replace(/[^a-z0-9]+/g, '_')
276
+ .replace(/^_+|_+$/g, '')
277
+ .slice(0, max) || 'x'
278
+ )
279
+ }
280
+
281
+ /** Tool names must satisfy provider limits: ^[a-zA-Z0-9_-]{1,64}$. */
282
+ export function functionName(serverId, toolName) {
283
+ const prefix = `mcp__${slug(serverId, 18)}__`
284
+ const tool = String(toolName).replace(/[^a-zA-Z0-9_-]/g, '_')
285
+ return `${prefix}${tool}`.slice(0, 64)
286
+ }
287
+
288
+ function flattenContent(result) {
289
+ const parts = Array.isArray(result?.content) ? result.content : []
290
+ const lines = []
291
+ for (const part of parts) {
292
+ if (!part || typeof part !== 'object') continue
293
+ if (part.type === 'text' && typeof part.text === 'string') lines.push(part.text)
294
+ else if (part.type === 'image') lines.push(`[image ${part.mimeType ?? ''} omitted]`)
295
+ else if (part.type === 'audio') lines.push(`[audio ${part.mimeType ?? ''} omitted]`)
296
+ else if (part.type === 'resource' && part.resource) {
297
+ lines.push(typeof part.resource.text === 'string' ? part.resource.text : `[resource ${part.resource.uri ?? ''}]`)
298
+ } else if (part.type === 'resource_link') lines.push(`[resource ${part.uri ?? ''}]`)
299
+ }
300
+ if (lines.length === 0 && result?.structuredContent !== undefined) {
301
+ lines.push(JSON.stringify(result.structuredContent, null, 2))
302
+ }
303
+ const text = lines.join('\n')
304
+ return text.length > MAX_RESULT_CHARS ? `${text.slice(0, MAX_RESULT_CHARS)}\n… truncated` : text
305
+ }
306
+
307
+ function maskRecord(record) {
308
+ const out = {}
309
+ for (const key of Object.keys(record ?? {})) out[key] = MASK
310
+ return out
311
+ }
312
+
313
+ export function sanitizeServerConfig(input, existing) {
314
+ const raw = input && typeof input === 'object' ? input : {}
315
+ const name = typeof raw.name === 'string' ? raw.name.trim().slice(0, 60) : ''
316
+ if (!name) throw new HttpError(400, 'Give each MCP server a name.', 'bad_mcp')
317
+ const transport = raw.transport === 'http' ? 'http' : 'stdio'
318
+ const id =
319
+ typeof raw.id === 'string' && /^[a-z0-9][a-z0-9-]{0,39}$/.test(raw.id)
320
+ ? raw.id
321
+ : slug(name, 30).replace(/_/g, '-') || 'server'
322
+
323
+ const keepSecret = (incoming, previous) => {
324
+ const out = {}
325
+ if (!incoming || typeof incoming !== 'object') return out
326
+ for (const [key, value] of Object.entries(incoming)) {
327
+ if (typeof value !== 'string' || key.length > 120) continue
328
+ out[key] = value === MASK ? previous?.[key] ?? '' : value.slice(0, 4000)
329
+ }
330
+ return out
331
+ }
332
+
333
+ const config = {
334
+ id,
335
+ name,
336
+ transport,
337
+ enabled: raw.enabled !== false,
338
+ approval: raw.approval === 'auto' ? 'auto' : 'ask',
339
+ disabledTools: Array.isArray(raw.disabledTools)
340
+ ? raw.disabledTools.filter((item) => typeof item === 'string').slice(0, 200)
341
+ : [],
342
+ }
343
+ if (transport === 'stdio') {
344
+ const command = typeof raw.command === 'string' ? raw.command.trim().slice(0, 500) : ''
345
+ if (!command) throw new HttpError(400, `"${name}" needs a command to start.`, 'bad_mcp')
346
+ config.command = command
347
+ config.args = Array.isArray(raw.args)
348
+ ? raw.args.filter((item) => typeof item === 'string').map((item) => item.slice(0, 1000)).slice(0, 60)
349
+ : []
350
+ const env = keepSecret(raw.env, existing?.env)
351
+ for (const key of Object.keys(env)) {
352
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) delete env[key]
353
+ }
354
+ config.env = env
355
+ } else {
356
+ const url = typeof raw.url === 'string' ? raw.url.trim().slice(0, 1000) : ''
357
+ if (!/^https?:\/\//i.test(url)) throw new HttpError(400, `"${name}" needs an http(s) URL.`, 'bad_mcp')
358
+ config.url = url
359
+ const headers = keepSecret(raw.headers, existing?.headers)
360
+ for (const key of Object.keys(headers)) {
361
+ if (!/^[A-Za-z0-9-]{1,80}$/.test(key)) delete headers[key]
362
+ }
363
+ config.headers = headers
364
+ }
365
+ return config
366
+ }
367
+
368
+ export class McpManager {
369
+ constructor() {
370
+ this.configs = new Map()
371
+ this.runtime = new Map()
372
+ this.getCwd = async () => os.homedir()
373
+ }
374
+
375
+ async setConfigs(configs) {
376
+ const next = new Map(configs.map((config) => [config.id, config]))
377
+ const stale = []
378
+ for (const [id, previous] of this.configs) {
379
+ const config = next.get(id)
380
+ if (!config || JSON.stringify(config) !== JSON.stringify(previous)) stale.push(id)
381
+ }
382
+ await Promise.all(stale.map((id) => this.disconnect(id)))
383
+ this.configs = next
384
+ for (const id of [...this.runtime.keys()]) if (!next.has(id)) this.runtime.delete(id)
385
+ }
386
+
387
+ list() {
388
+ return [...this.configs.values()].map((config) => this.view(config))
389
+ }
390
+
391
+ view(config) {
392
+ const state = this.runtime.get(config.id) ?? {}
393
+ const base = {
394
+ id: config.id,
395
+ name: config.name,
396
+ transport: config.transport,
397
+ enabled: config.enabled,
398
+ approval: config.approval,
399
+ disabledTools: config.disabledTools ?? [],
400
+ status: state.status ?? (config.enabled ? 'disconnected' : 'disabled'),
401
+ error: state.error ?? '',
402
+ serverInfo: state.serverInfo ?? null,
403
+ connectedAt: state.connectedAt ?? null,
404
+ tools: (state.tools ?? []).map((tool) => ({
405
+ name: tool.name,
406
+ fn: functionName(config.id, tool.name),
407
+ description: typeof tool.description === 'string' ? tool.description.slice(0, 1200) : '',
408
+ inputSchema:
409
+ tool.inputSchema && typeof tool.inputSchema === 'object'
410
+ ? tool.inputSchema
411
+ : { type: 'object', properties: {} },
412
+ readOnly: tool.annotations?.readOnlyHint === true,
413
+ })),
414
+ }
415
+ if (config.transport === 'stdio') {
416
+ return { ...base, command: config.command, args: config.args ?? [], env: maskRecord(config.env) }
417
+ }
418
+ return { ...base, url: config.url, headers: maskRecord(config.headers) }
419
+ }
420
+
421
+ async connect(id) {
422
+ const config = this.configs.get(id)
423
+ if (!config) throw new HttpError(404, 'Unknown MCP server.', 'mcp_missing')
424
+ await this.disconnect(id)
425
+ const state = { status: 'connecting', error: '', tools: [] }
426
+ this.runtime.set(id, state)
427
+ try {
428
+ const transport =
429
+ config.transport === 'http'
430
+ ? new HttpTransport(config)
431
+ : new StdioTransport(config, {
432
+ cwd: await this.getCwd(),
433
+ onNotification: (method) => {
434
+ if (method === 'notifications/tools/list_changed') void this.refresh(id).catch(() => undefined)
435
+ },
436
+ })
437
+ state.transport = transport
438
+ await transport.start()
439
+ if (config.transport === 'stdio') {
440
+ transport.onExit = (error) => {
441
+ if (this.runtime.get(id) === state && state.status === 'connected') {
442
+ state.status = 'error'
443
+ state.error = error.message
444
+ }
445
+ }
446
+ }
447
+ const init = await withTimeout(
448
+ transport.request('initialize', {
449
+ protocolVersion: PROTOCOL_VERSION,
450
+ capabilities: {},
451
+ clientInfo: { name: 'OpenSol', version: '3.1.0' },
452
+ }),
453
+ CONNECT_TIMEOUT_MS,
454
+ 'initialize',
455
+ )
456
+ await transport.notify('notifications/initialized')
457
+ state.serverInfo = init?.serverInfo ?? null
458
+ state.tools = await this.#listTools(transport)
459
+ state.status = 'connected'
460
+ state.connectedAt = Date.now()
461
+ } catch (error) {
462
+ state.status = 'error'
463
+ state.error = error instanceof Error ? error.message : 'Could not connect.'
464
+ await state.transport?.close().catch(() => undefined)
465
+ state.transport = null
466
+ }
467
+ return this.view(config)
468
+ }
469
+
470
+ async #listTools(transport) {
471
+ const tools = []
472
+ let cursor
473
+ for (let page = 0; page < 10; page += 1) {
474
+ const result = await transport.request('tools/list', cursor ? { cursor } : {}, 30_000)
475
+ if (Array.isArray(result?.tools)) tools.push(...result.tools.filter((tool) => tool && typeof tool.name === 'string'))
476
+ cursor = result?.nextCursor
477
+ if (!cursor) break
478
+ }
479
+ return tools
480
+ }
481
+
482
+ async refresh(id) {
483
+ const config = this.configs.get(id)
484
+ const state = this.runtime.get(id)
485
+ if (!config) throw new HttpError(404, 'Unknown MCP server.', 'mcp_missing')
486
+ if (!state?.transport || state.status !== 'connected') return this.connect(id)
487
+ state.tools = await this.#listTools(state.transport)
488
+ return this.view(config)
489
+ }
490
+
491
+ async disconnect(id) {
492
+ const state = this.runtime.get(id)
493
+ if (state?.transport) await state.transport.close().catch(() => undefined)
494
+ const config = this.configs.get(id)
495
+ this.runtime.set(id, { status: config?.enabled === false ? 'disabled' : 'disconnected', error: '', tools: [] })
496
+ return config ? this.view(config) : null
497
+ }
498
+
499
+ async connectAll() {
500
+ await Promise.all(
501
+ [...this.configs.values()].filter((config) => config.enabled).map((config) => this.connect(config.id)),
502
+ )
503
+ }
504
+
505
+ async shutdown() {
506
+ await Promise.all([...this.runtime.keys()].map((id) => this.disconnect(id)))
507
+ }
508
+
509
+ async callTool(id, toolName, args) {
510
+ const config = this.configs.get(id)
511
+ const state = this.runtime.get(id)
512
+ if (!config) throw new HttpError(404, 'Unknown MCP server.', 'mcp_missing')
513
+ if (!state?.transport || state.status !== 'connected') {
514
+ throw new HttpError(409, `${config.name} is not connected. Reconnect it in Settings → MCP.`, 'mcp_disconnected')
515
+ }
516
+ if ((config.disabledTools ?? []).includes(toolName)) {
517
+ throw new HttpError(403, `${toolName} is turned off for ${config.name}.`, 'mcp_tool_disabled')
518
+ }
519
+ try {
520
+ const result = await state.transport.request('tools/call', {
521
+ name: toolName,
522
+ arguments: args && typeof args === 'object' ? args : {},
523
+ })
524
+ return { text: flattenContent(result), isError: result?.isError === true }
525
+ } catch (error) {
526
+ if (error instanceof RpcError) return { text: error.message, isError: true }
527
+ throw new HttpError(502, error instanceof Error ? error.message : 'The MCP call failed.', 'mcp_failed')
528
+ }
529
+ }
530
+ }
531
+
532
+ export const mcp = new McpManager()