@bibobonking/opensol 3.1.0 → 3.1.2

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.
@@ -1,8 +1,19 @@
1
- // Minimal Model Context Protocol client (stdio + Streamable HTTP), zero dependencies.
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
- 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.')
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
- ...(this.config.headers ?? {}),
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 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
- })
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 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
- })
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' ? 'http' : 'stdio'
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
- : 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
- })
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 === 'stdio') {
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
- state.status = 'error'
463
- state.error = error instanceof Error ? error.message : 'Could not connect.'
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 Settings → MCP.`, 'mcp_disconnected')
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()
@@ -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 OpenSoft
38
+ OpenSol <span className="px-1 text-line">/</span> by OpenSoftX
39
39
  </p>
40
40
  )
41
41
  }