@bibobonking/opensol 3.1.0 → 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/CHANGELOG.md +4 -1
- package/README.md +2 -2
- package/desktop/src-tauri/Cargo.toml +1 -1
- 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 +1 -1
- package/server/README.md +2 -2
- package/server/daemon.mjs +74 -0
- package/server/lib/mcp-oauth.mjs +219 -0
- package/server/lib/mcp-oauth.test.mjs +175 -0
- package/server/lib/mcp.mjs +404 -37
- package/src/App.tsx +2 -0
- package/src/components/CommandPalette.tsx +14 -1
- package/src/components/Logo.tsx +1 -1
- package/src/components/McpPanel.tsx +687 -0
- package/src/components/ModelDeck.tsx +12 -3
- package/src/components/SettingsModal.tsx +43 -5
- package/src/components/Sidebar.tsx +2 -1
- package/src/components/Splash.tsx +1 -1
- package/src/data/providers.ts +1 -1
- package/src/lib/daemon.ts +10 -1
- package/src/lib/mcp-config.test.mjs +47 -0
- package/src/lib/mcp-config.ts +203 -0
- package/src/store/mcp.ts +28 -1
- package/src/store/settings.ts +1 -1
- package/src/store/ui.ts +4 -0
- package/src/types.ts +14 -2
- package/tsconfig.tsbuildinfo +1 -1
- package/dist/assets/index-DdI1_iPD.css +0 -1
- package/dist/assets/index-egG5Bgfo.js +0 -312
- package/src/components/McpTab.tsx +0 -337
package/server/lib/mcp.mjs
CHANGED
|
@@ -1,8 +1,19 @@
|
|
|
1
|
-
// Minimal Model Context Protocol client (stdio
|
|
1
|
+
// Minimal Model Context Protocol client (stdio, Streamable HTTP, legacy HTTP+SSE, OAuth), zero dependencies.
|
|
2
2
|
// The daemon owns every connection: the browser only sees tool metadata and results.
|
|
3
3
|
import { spawn } from 'node:child_process'
|
|
4
4
|
import os from 'node:os'
|
|
5
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'
|
|
6
17
|
|
|
7
18
|
const PROTOCOL_VERSION = '2025-03-26'
|
|
8
19
|
const CONNECT_TIMEOUT_MS = 60_000
|
|
@@ -191,31 +202,90 @@ async function readSse(response, wantedId) {
|
|
|
191
202
|
throw new Error('The MCP server closed the stream without answering.')
|
|
192
203
|
}
|
|
193
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
|
+
|
|
194
270
|
/** Streamable HTTP transport: one POST per message, JSON or SSE replies. */
|
|
195
271
|
class HttpTransport {
|
|
196
|
-
constructor(config) {
|
|
272
|
+
constructor(config, { auth } = {}) {
|
|
197
273
|
this.config = config
|
|
274
|
+
this.auth = auth
|
|
198
275
|
this.sessionId = ''
|
|
199
276
|
}
|
|
200
277
|
|
|
201
278
|
async start() {
|
|
202
|
-
|
|
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.')
|
|
279
|
+
checkRemoteUrl(this.config.url)
|
|
209
280
|
}
|
|
210
281
|
|
|
211
282
|
#headers() {
|
|
212
|
-
return {
|
|
283
|
+
return remoteHeaders(this.config, this.auth, {
|
|
213
284
|
'Content-Type': 'application/json',
|
|
214
285
|
Accept: 'application/json, text/event-stream',
|
|
215
286
|
'MCP-Protocol-Version': PROTOCOL_VERSION,
|
|
216
287
|
...(this.sessionId ? { 'Mcp-Session-Id': this.sessionId } : {}),
|
|
217
|
-
|
|
218
|
-
}
|
|
288
|
+
})
|
|
219
289
|
}
|
|
220
290
|
|
|
221
291
|
async request(method, params, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
@@ -223,12 +293,16 @@ class HttpTransport {
|
|
|
223
293
|
const controller = new AbortController()
|
|
224
294
|
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
225
295
|
try {
|
|
226
|
-
const response = await
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
+
)
|
|
232
306
|
const session = response.headers.get('mcp-session-id')
|
|
233
307
|
if (session) this.sessionId = session
|
|
234
308
|
if (!response.ok) {
|
|
@@ -249,12 +323,16 @@ class HttpTransport {
|
|
|
249
323
|
}
|
|
250
324
|
|
|
251
325
|
async notify(method, params) {
|
|
252
|
-
const response = await
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
+
)
|
|
258
336
|
await response.body?.cancel().catch(() => undefined)
|
|
259
337
|
}
|
|
260
338
|
|
|
@@ -262,12 +340,143 @@ class HttpTransport {
|
|
|
262
340
|
if (!this.sessionId) return
|
|
263
341
|
await fetch(this.config.url, {
|
|
264
342
|
method: 'DELETE',
|
|
265
|
-
headers: this.#headers(),
|
|
343
|
+
headers: await this.#headers(),
|
|
266
344
|
signal: AbortSignal.timeout(3000),
|
|
267
345
|
}).catch(() => undefined)
|
|
268
346
|
}
|
|
269
347
|
}
|
|
270
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
|
+
|
|
271
480
|
function slug(value, max) {
|
|
272
481
|
return (
|
|
273
482
|
String(value ?? '')
|
|
@@ -314,7 +523,7 @@ export function sanitizeServerConfig(input, existing) {
|
|
|
314
523
|
const raw = input && typeof input === 'object' ? input : {}
|
|
315
524
|
const name = typeof raw.name === 'string' ? raw.name.trim().slice(0, 60) : ''
|
|
316
525
|
if (!name) throw new HttpError(400, 'Give each MCP server a name.', 'bad_mcp')
|
|
317
|
-
const transport = raw.transport === 'http'
|
|
526
|
+
const transport = raw.transport === 'http' || raw.transport === 'sse' ? raw.transport : 'stdio'
|
|
318
527
|
const id =
|
|
319
528
|
typeof raw.id === 'string' && /^[a-z0-9][a-z0-9-]{0,39}$/.test(raw.id)
|
|
320
529
|
? raw.id
|
|
@@ -361,6 +570,16 @@ export function sanitizeServerConfig(input, existing) {
|
|
|
361
570
|
if (!/^[A-Za-z0-9-]{1,80}$/.test(key)) delete headers[key]
|
|
362
571
|
}
|
|
363
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
|
+
}
|
|
364
583
|
}
|
|
365
584
|
return config
|
|
366
585
|
}
|
|
@@ -370,6 +589,122 @@ export class McpManager {
|
|
|
370
589
|
this.configs = new Map()
|
|
371
590
|
this.runtime = new Map()
|
|
372
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
|
|
373
708
|
}
|
|
374
709
|
|
|
375
710
|
async setConfigs(configs) {
|
|
@@ -401,6 +736,15 @@ export class McpManager {
|
|
|
401
736
|
error: state.error ?? '',
|
|
402
737
|
serverInfo: state.serverInfo ?? null,
|
|
403
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,
|
|
404
748
|
tools: (state.tools ?? []).map((tool) => ({
|
|
405
749
|
name: tool.name,
|
|
406
750
|
fn: functionName(config.id, tool.name),
|
|
@@ -425,18 +769,20 @@ export class McpManager {
|
|
|
425
769
|
const state = { status: 'connecting', error: '', tools: [] }
|
|
426
770
|
this.runtime.set(id, state)
|
|
427
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)
|
|
428
776
|
const transport =
|
|
429
777
|
config.transport === 'http'
|
|
430
|
-
? new HttpTransport(config)
|
|
431
|
-
:
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
if (method === 'notifications/tools/list_changed') void this.refresh(id).catch(() => undefined)
|
|
435
|
-
},
|
|
436
|
-
})
|
|
778
|
+
? new HttpTransport(config, { auth })
|
|
779
|
+
: config.transport === 'sse'
|
|
780
|
+
? new SseTransport(config, { auth, onNotification })
|
|
781
|
+
: new StdioTransport(config, { cwd: await this.getCwd(), onNotification })
|
|
437
782
|
state.transport = transport
|
|
783
|
+
state.authorized = Boolean(auth && (await this.authStore.get(id))?.tokens)
|
|
438
784
|
await transport.start()
|
|
439
|
-
if (config.transport
|
|
785
|
+
if (config.transport !== 'http') {
|
|
440
786
|
transport.onExit = (error) => {
|
|
441
787
|
if (this.runtime.get(id) === state && state.status === 'connected') {
|
|
442
788
|
state.status = 'error'
|
|
@@ -458,9 +804,22 @@ export class McpManager {
|
|
|
458
804
|
state.tools = await this.#listTools(transport)
|
|
459
805
|
state.status = 'connected'
|
|
460
806
|
state.connectedAt = Date.now()
|
|
807
|
+
state.authorized = Boolean(auth && (await this.authStore.get(id))?.tokens)
|
|
461
808
|
} catch (error) {
|
|
462
|
-
|
|
463
|
-
|
|
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
|
+
}
|
|
464
823
|
await state.transport?.close().catch(() => undefined)
|
|
465
824
|
state.transport = null
|
|
466
825
|
}
|
|
@@ -511,7 +870,7 @@ export class McpManager {
|
|
|
511
870
|
const state = this.runtime.get(id)
|
|
512
871
|
if (!config) throw new HttpError(404, 'Unknown MCP server.', 'mcp_missing')
|
|
513
872
|
if (!state?.transport || state.status !== 'connected') {
|
|
514
|
-
throw new HttpError(409, `${config.name} is not connected. Reconnect it in
|
|
873
|
+
throw new HttpError(409, `${config.name} is not connected. Reconnect it in the MCP panel.`, 'mcp_disconnected')
|
|
515
874
|
}
|
|
516
875
|
if ((config.disabledTools ?? []).includes(toolName)) {
|
|
517
876
|
throw new HttpError(403, `${toolName} is turned off for ${config.name}.`, 'mcp_tool_disabled')
|
|
@@ -524,6 +883,14 @@ export class McpManager {
|
|
|
524
883
|
return { text: flattenContent(result), isError: result?.isError === true }
|
|
525
884
|
} catch (error) {
|
|
526
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
|
+
}
|
|
527
894
|
throw new HttpError(502, error instanceof Error ? error.message : 'The MCP call failed.', 'mcp_failed')
|
|
528
895
|
}
|
|
529
896
|
}
|
package/src/App.tsx
CHANGED
|
@@ -4,6 +4,7 @@ import { ChatView } from './components/ChatView'
|
|
|
4
4
|
import { WorkspacePanel } from './components/WorkspacePanel'
|
|
5
5
|
import { SettingsModal } from './components/SettingsModal'
|
|
6
6
|
import { DeployModal } from './components/DeployModal'
|
|
7
|
+
import { McpPanel } from './components/McpPanel'
|
|
7
8
|
import { CommandPalette } from './components/CommandPalette'
|
|
8
9
|
import { Splash } from './components/Splash'
|
|
9
10
|
import { ConfirmDialog, ToastHost } from './components/ui'
|
|
@@ -138,6 +139,7 @@ export default function App() {
|
|
|
138
139
|
<WorkspacePanel />
|
|
139
140
|
</div>
|
|
140
141
|
<SettingsModal />
|
|
142
|
+
<McpPanel />
|
|
141
143
|
<DeployModal />
|
|
142
144
|
<CommandPalette />
|
|
143
145
|
<ConfirmDialog />
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
MessageSquare,
|
|
12
12
|
Moon,
|
|
13
13
|
Palette,
|
|
14
|
+
Plug,
|
|
14
15
|
Plus,
|
|
15
16
|
Rocket,
|
|
16
17
|
Search,
|
|
@@ -39,6 +40,7 @@ export function CommandPalette() {
|
|
|
39
40
|
const open = useUi((state) => state.paletteOpen)
|
|
40
41
|
const setOpen = useUi((state) => state.setPaletteOpen)
|
|
41
42
|
const openSettings = useUi((state) => state.openSettings)
|
|
43
|
+
const setMcpOpen = useUi((state) => state.setMcpOpen)
|
|
42
44
|
const setWorkspaceOpen = useUi((state) => state.setWorkspaceOpen)
|
|
43
45
|
const setDeployOpen = useUi((state) => state.setDeployOpen)
|
|
44
46
|
const focusComposer = useUi((state) => state.focusComposer)
|
|
@@ -146,6 +148,17 @@ export function CommandPalette() {
|
|
|
146
148
|
close()
|
|
147
149
|
},
|
|
148
150
|
},
|
|
151
|
+
{
|
|
152
|
+
id: 'action-mcp',
|
|
153
|
+
label: 'MCP servers',
|
|
154
|
+
hint: 'Connections, OAuth sign-in and tools',
|
|
155
|
+
group: 'Settings',
|
|
156
|
+
icon: Plug,
|
|
157
|
+
action: () => {
|
|
158
|
+
close()
|
|
159
|
+
setMcpOpen(true)
|
|
160
|
+
},
|
|
161
|
+
},
|
|
149
162
|
{
|
|
150
163
|
id: 'action-skills',
|
|
151
164
|
label: 'Skills',
|
|
@@ -241,7 +254,7 @@ export function CommandPalette() {
|
|
|
241
254
|
}))
|
|
242
255
|
|
|
243
256
|
return [...actions, ...chatCommands, ...fileCommands]
|
|
244
|
-
}, [sessions, entries, focusComposer, notify, openSettings, setOpen, setWorkspaceOpen, setDeployOpen])
|
|
257
|
+
}, [sessions, entries, focusComposer, notify, openSettings, setMcpOpen, setOpen, setWorkspaceOpen, setDeployOpen])
|
|
245
258
|
|
|
246
259
|
const filtered = useMemo(() => {
|
|
247
260
|
const needle = query.trim().toLowerCase()
|
package/src/components/Logo.tsx
CHANGED
|
@@ -35,7 +35,7 @@ export function LogoLockup({ compact = false }: { compact?: boolean }) {
|
|
|
35
35
|
export function BrandFooter() {
|
|
36
36
|
return (
|
|
37
37
|
<p className="text-[11px] tracking-[0.01em] text-faint">
|
|
38
|
-
OpenSol <span className="px-1 text-line">/</span> by
|
|
38
|
+
OpenSol <span className="px-1 text-line">/</span> by OpenSoftX
|
|
39
39
|
</p>
|
|
40
40
|
)
|
|
41
41
|
}
|