@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.
- package/.github/workflows/build.yml +60 -8
- package/CHANGELOG.md +21 -0
- package/README.md +2 -2
- 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-CeN54A2N.css +1 -0
- package/dist/assets/index-fhIO9a7_.js +318 -0
- package/dist/index.html +4 -4
- package/index.html +2 -2
- package/package.json +2 -1
- package/server/README.md +2 -2
- package/server/daemon.mjs +138 -2
- package/server/lib/mcp-oauth.mjs +219 -0
- package/server/lib/mcp-oauth.test.mjs +175 -0
- package/server/lib/mcp.mjs +899 -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/App.tsx +2 -0
- package/src/agent/tools.ts +6 -5
- package/src/components/CommandPalette.tsx +14 -1
- package/src/components/Composer.tsx +22 -38
- package/src/components/DiffView.tsx +98 -0
- package/src/components/Logo.tsx +1 -1
- package/src/components/McpPanel.tsx +687 -0
- package/src/components/MessageItem.tsx +62 -13
- package/src/components/ModelDeck.tsx +661 -0
- package/src/components/SessionsTab.tsx +206 -0
- package/src/components/SettingsModal.tsx +146 -71
- package/src/components/Sidebar.tsx +79 -24
- package/src/components/Splash.tsx +1 -1
- 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/data/providers.ts +1 -1
- package/src/index.css +97 -1
- package/src/lib/daemon.ts +38 -0
- package/src/lib/diff.ts +62 -0
- package/src/lib/mcp-config.test.mjs +47 -0
- package/src/lib/mcp-config.ts +203 -0
- package/src/lib/permissions.ts +6 -6
- package/src/store/chats.ts +9 -1
- package/src/store/mcp.ts +94 -0
- package/src/store/settings.ts +10 -7
- package/src/store/ui.ts +17 -0
- package/src/store/workspace.ts +26 -0
- package/src/types.ts +63 -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
|
@@ -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()
|