@bibobonking/opensol 3.0.2 → 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.
- package/.github/workflows/build.yml +60 -8
- package/CHANGELOG.md +18 -0
- package/README.md +15 -12
- package/desktop/README.md +39 -0
- package/desktop/loading/index.html +36 -0
- package/desktop/src-tauri/Cargo.lock +4301 -0
- package/desktop/src-tauri/Cargo.toml +23 -0
- package/desktop/src-tauri/binaries/.gitkeep +0 -0
- package/desktop/src-tauri/build.rs +3 -0
- package/desktop/src-tauri/capabilities/default.json +7 -0
- package/desktop/src-tauri/icons/128x128.png +0 -0
- package/desktop/src-tauri/icons/128x128@2x.png +0 -0
- package/desktop/src-tauri/icons/32x32.png +0 -0
- package/desktop/src-tauri/icons/64x64.png +0 -0
- package/desktop/src-tauri/icons/Square107x107Logo.png +0 -0
- package/desktop/src-tauri/icons/Square142x142Logo.png +0 -0
- package/desktop/src-tauri/icons/Square150x150Logo.png +0 -0
- package/desktop/src-tauri/icons/Square284x284Logo.png +0 -0
- package/desktop/src-tauri/icons/Square30x30Logo.png +0 -0
- package/desktop/src-tauri/icons/Square310x310Logo.png +0 -0
- package/desktop/src-tauri/icons/Square44x44Logo.png +0 -0
- package/desktop/src-tauri/icons/Square71x71Logo.png +0 -0
- package/desktop/src-tauri/icons/Square89x89Logo.png +0 -0
- package/desktop/src-tauri/icons/StoreLogo.png +0 -0
- package/desktop/src-tauri/icons/icon.icns +0 -0
- package/desktop/src-tauri/icons/icon.ico +0 -0
- package/desktop/src-tauri/icons/icon.png +0 -0
- package/desktop/src-tauri/src/lib.rs +210 -0
- package/desktop/src-tauri/src/main.rs +6 -0
- package/desktop/src-tauri/tauri.conf.json +48 -0
- package/dist/assets/index-DdI1_iPD.css +1 -0
- package/dist/assets/index-egG5Bgfo.js +312 -0
- package/dist/index.html +2 -2
- package/package.json +2 -1
- package/server/daemon.mjs +64 -2
- package/server/lib/mcp.mjs +532 -0
- package/server/lib/mcp.test.mjs +46 -0
- package/server/lib/store.mjs +22 -0
- package/server/lib/tools.mjs +59 -1
- package/src/agent/tools.ts +6 -5
- package/src/components/Composer.tsx +22 -38
- package/src/components/DiffView.tsx +98 -0
- package/src/components/McpTab.tsx +337 -0
- package/src/components/MessageItem.tsx +62 -13
- package/src/components/ModelDeck.tsx +652 -0
- package/src/components/SessionsTab.tsx +206 -0
- package/src/components/SettingsModal.tsx +106 -69
- package/src/components/Sidebar.tsx +78 -24
- package/src/components/ToolCallCard.tsx +29 -7
- package/src/components/WorkspacePanel.tsx +16 -1
- package/src/core/kernel.ts +2 -0
- package/src/core/mcp-tools.ts +63 -0
- package/src/core/plugins/tools/fs.ts +24 -3
- package/src/core/services/agent-loop.ts +3 -1
- package/src/core/services/approval.ts +2 -1
- package/src/core/services/titles.ts +135 -0
- package/src/core/services/tools.ts +11 -8
- package/src/core/types.ts +4 -0
- package/src/index.css +97 -1
- package/src/lib/daemon.ts +29 -0
- package/src/lib/diff.ts +62 -0
- package/src/lib/permissions.ts +6 -6
- package/src/store/chats.ts +9 -1
- package/src/store/mcp.ts +67 -0
- package/src/store/settings.ts +9 -6
- package/src/store/ui.ts +13 -0
- package/src/store/workspace.ts +26 -0
- package/src/types.ts +51 -2
- package/tsconfig.tsbuildinfo +1 -1
- package/dist/assets/index-BmN7pBji.css +0 -1
- package/dist/assets/index-DgNDjkOz.js +0 -301
- 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-
|
|
35
|
-
<link rel="stylesheet" crossorigin href="./assets/index-
|
|
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
|
|
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
|
|
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
|
-
|
|
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()
|