@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.
@@ -181,6 +181,7 @@ export function ModelDeck({ open, onClose }: { open: boolean; onClose: () => voi
181
181
  const listRef = useRef<HTMLDivElement>(null)
182
182
  const searchRef = useRef<HTMLInputElement>(null)
183
183
 
184
+ const railRef = useRef<HTMLElement>(null)
184
185
  const provider = settings.provider
185
186
  const models = useModelCatalog((state) => state.byProvider[provider]) ?? NO_MODELS
186
187
  const loading = useModelCatalog((state) => Boolean(state.loading[provider]))
@@ -229,7 +230,7 @@ export function ModelDeck({ open, onClose }: { open: boolean; onClose: () => voi
229
230
  }, [query, provider])
230
231
 
231
232
  useEffect(() => {
232
- listRef.current?.querySelector<HTMLElement>(`[data-row="${cursor}"]`)?.scrollIntoView({ block: 'nearest' })
233
+ listRef.current?.querySelector<HTMLElement>(`[data-row="${cursor}"]`)?.scrollIntoView({ block: 'center' })
233
234
  }, [cursor])
234
235
 
235
236
  const choose = (model: ModelInfo) => {
@@ -281,6 +282,13 @@ export function ModelDeck({ open, onClose }: { open: boolean; onClose: () => voi
281
282
  return () => document.removeEventListener('keydown', onKey)
282
283
  }, [open, onClose])
283
284
 
285
+ // Keep the active provider in view: the rail scrolls and the selected one can sit far down.
286
+ useEffect(() => {
287
+ if (!open) return
288
+ const frame = requestAnimationFrame(() => railRef.current?.querySelector('[aria-pressed="true"]')?.scrollIntoView({ block: 'center' }))
289
+ return () => cancelAnimationFrame(frame)
290
+ }, [open, provider])
291
+
284
292
  if (!open) return null
285
293
 
286
294
  const needsKey = !connectedNow && !keyless
@@ -355,12 +363,13 @@ export function ModelDeck({ open, onClose }: { open: boolean; onClose: () => voi
355
363
  <div className="relative flex min-h-0 flex-1 flex-col border-t border-line md:flex-row">
356
364
  {/* Provider rail */}
357
365
  <nav
366
+ ref={railRef}
358
367
  aria-label="Providers"
359
368
  className="thin-scroll relative flex shrink-0 gap-1.5 overflow-x-auto border-b border-line bg-canvas/70 p-2 md:w-[78px] md:flex-col md:overflow-y-auto md:overflow-x-hidden md:border-b-0 md:border-r md:p-2.5"
360
369
  >
361
370
  <span
362
371
  aria-hidden
363
- className="deck-rail-glow pointer-events-none absolute left-2.5 hidden h-[46px] w-[calc(100%-20px)] rounded-2xl bg-accent-soft ring-1 ring-accent/40 transition-transform duration-500 ease-spring md:block"
372
+ className="deck-rail-glow pointer-events-none absolute left-2.5 top-2.5 hidden h-[46px] w-[calc(100%-20px)] rounded-2xl bg-accent-soft ring-1 ring-accent/40 transition-transform duration-500 ease-spring md:block"
364
373
  style={{ transform: `translateY(${railIndex * 52}px)` }}
365
374
  />
366
375
  {PROVIDERS.map((entry) => {
@@ -375,7 +384,7 @@ export function ModelDeck({ open, onClose }: { open: boolean; onClose: () => voi
375
384
  title={`${entry.name}${ready ? '' : ' · connect to enable'}`}
376
385
  aria-pressed={selected}
377
386
  className={cn(
378
- 'relative flex h-[46px] min-w-[58px] shrink-0 flex-col items-center justify-center gap-0.5 rounded-2xl transition-colors disabled:cursor-not-allowed disabled:opacity-40 md:mb-[6px] md:min-w-0',
387
+ 'relative flex h-[46px] min-w-[58px] shrink-0 flex-col items-center justify-center gap-0.5 rounded-2xl transition-colors disabled:cursor-not-allowed disabled:opacity-40 md:min-w-0',
379
388
  selected ? 'text-accent max-md:bg-accent-soft' : 'text-muted hover:bg-raised hover:text-ink',
380
389
  )}
381
390
  >
@@ -61,7 +61,7 @@ import { PERMISSION_MODES, permissionMode, permissionTools } from '../lib/permis
61
61
  import { cn, formatBytes, readFileAsText } from '../lib/utils'
62
62
  import { LogoMark } from './Logo'
63
63
  import { PluginManager } from './PluginManager'
64
- import { McpTab } from './McpTab'
64
+ import { useMcp } from '../store/mcp'
65
65
  import { pluginRuntime } from '../plugins'
66
66
  import { Modal, Segmented, Spinner, Switch } from './ui'
67
67
 
@@ -72,7 +72,7 @@ const TABS = [
72
72
  { id: 'search', label: 'Search', icon: Globe2, description: 'Give the agent current web results with your own key.' },
73
73
  { id: 'skills', label: 'Skills', icon: Sparkles, description: 'Instructions added to every request when enabled.' },
74
74
  { id: 'plugins', label: 'Plugins', icon: Package, description: 'Install local plugin packages, review what they ask for, and run them once you trust the code.' },
75
- { id: 'mcp', label: 'MCP', icon: Link2, description: 'Connect Model Context Protocol servers and choose which of their tools the agent may call.' },
75
+ { id: 'mcp', label: 'MCP', icon: Link2, description: 'Model Context Protocol servers now have their own panel, with OAuth sign-in and local, HTTP and SSE connections.' },
76
76
  { id: 'deploy', label: 'Deploy', icon: Rocket, description: 'Hosting credentials and command-line tools.' },
77
77
  { id: 'appearance', label: 'Appearance', icon: Palette, description: 'Theme and interface preferences.' },
78
78
  { id: 'about', label: 'About', icon: Info, description: 'Local storage, history and reset controls.' },
@@ -1792,10 +1792,10 @@ function AboutTab() {
1792
1792
  <h3 className="font-display text-[20px] font-semibold tracking-[-0.03em] text-ink">OpenSol</h3>
1793
1793
  <span className="text-[10px] text-faint">v{daemon?.version ?? '1.0.0'}</span>
1794
1794
  </div>
1795
- <p className="mt-0.5 text-[10.5px] font-medium text-muted">A OpenSoft product</p>
1795
+ <p className="mt-0.5 text-[10.5px] font-medium text-muted">An OpenSoftX product</p>
1796
1796
  <p className="mt-2 max-w-[520px] text-[10.5px] leading-relaxed text-muted">
1797
1797
  A local-first coding agent with a real workspace, shell, git, web research, subagents and deploy flows.
1798
- No OpenSol account and no OpenSoft server. Your keys never leave this machine.
1798
+ No OpenSol account and no OpenSoftX server. Your keys never leave this machine.
1799
1799
  </p>
1800
1800
  </div>
1801
1801
  </div>
@@ -1950,6 +1950,44 @@ function AboutTab() {
1950
1950
  )
1951
1951
  }
1952
1952
 
1953
+ function McpLauncher() {
1954
+ const servers = useMcp((state) => state.servers)
1955
+ const loaded = useMcp((state) => state.loaded)
1956
+ const load = useMcp((state) => state.load)
1957
+ const setMcpOpen = useUi((state) => state.setMcpOpen)
1958
+ useEffect(() => {
1959
+ void load()
1960
+ }, [load])
1961
+ const connected = servers.filter((server) => server.status === 'connected').length
1962
+ const waiting = servers.filter((server) => server.status === 'needs_auth').length
1963
+ return (
1964
+ <div className="space-y-5 px-5 py-6 sm:px-8">
1965
+ <div className="settings-card flex flex-wrap items-center gap-4">
1966
+ <span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-accent-soft text-accent">
1967
+ <Link2 size={18} />
1968
+ </span>
1969
+ <div className="min-w-0 flex-1">
1970
+ <p className="settings-label">MCP panel</p>
1971
+ <p className="settings-hint">
1972
+ {!loaded
1973
+ ? 'Loading…'
1974
+ : servers.length === 0
1975
+ ? 'No servers yet. Add a local command or a remote URL, with OAuth where the service asks for it.'
1976
+ : `${connected} of ${servers.length} server${servers.length === 1 ? '' : 's'} connected${waiting ? ` · ${waiting} waiting for sign-in` : ''}.`}
1977
+ </p>
1978
+ </div>
1979
+ <button
1980
+ type="button"
1981
+ onClick={() => setMcpOpen(true)}
1982
+ className="inline-flex h-9 shrink-0 items-center gap-1.5 rounded-full bg-ink px-4 text-[12px] font-medium text-panel transition-transform active:scale-95"
1983
+ >
1984
+ Open MCP panel
1985
+ </button>
1986
+ </div>
1987
+ </div>
1988
+ )
1989
+ }
1990
+
1953
1991
  export function SettingsModal() {
1954
1992
  const open = useUi((state) => state.settingsOpen)
1955
1993
  const tab = useUi((state) => state.settingsTab)
@@ -2076,7 +2114,7 @@ export function SettingsModal() {
2076
2114
  {tab === 'search' && <SearchTab />}
2077
2115
  {tab === 'skills' && <SkillsTab />}
2078
2116
  {tab === 'plugins' && <PluginsTab />}
2079
- {tab === 'mcp' && <McpTab />}
2117
+ {tab === 'mcp' && <McpLauncher />}
2080
2118
  {tab === 'deploy' && <DeployTab />}
2081
2119
  {tab === 'appearance' && <AppearanceTab />}
2082
2120
  {tab === 'about' && <AboutTab />}
@@ -56,6 +56,7 @@ export function Sidebar() {
56
56
  const openSettings = useUi((state) => state.openSettings)
57
57
  const setWorkspaceOpen = useUi((state) => state.setWorkspaceOpen)
58
58
  const setDeployOpen = useUi((state) => state.setDeployOpen)
59
+ const setMcpOpen = useUi((state) => state.setMcpOpen)
59
60
  const askConfirm = useUi((state) => state.askConfirm)
60
61
  const notify = useUi((state) => state.notify)
61
62
  const focusComposer = useUi((state) => state.focusComposer)
@@ -255,7 +256,7 @@ export function Sidebar() {
255
256
  type="button"
256
257
  onClick={() => {
257
258
  setMobileOpen(false)
258
- openSettings('mcp')
259
+ setMcpOpen(true)
259
260
  }}
260
261
  className="nav-item"
261
262
  >
@@ -62,7 +62,7 @@ export function Splash({
62
62
  ))}
63
63
  </h1>
64
64
  <p className="mt-2 animate-fade text-[11px] font-medium uppercase tracking-[0.22em] text-faint [animation-delay:1s]">
65
- by OpenSoft
65
+ by OpenSoftX
66
66
  </p>
67
67
 
68
68
  {phase === 'error' ? (
@@ -7,7 +7,7 @@ export const PROVIDERS: ProviderDef[] = [
7
7
  format: 'openai',
8
8
  baseUrl: 'https://api.openai.com/v1',
9
9
  keyHint: 'sk-…',
10
- note: 'GPT and Codex models. Requests are made by your local daemon, never by OpenSoft.',
10
+ note: 'GPT and Codex models. Requests are made by your local daemon, never by OpenSoftX.',
11
11
  },
12
12
  {
13
13
  id: 'anthropic',
package/src/lib/daemon.ts CHANGED
@@ -280,7 +280,8 @@ export function removeWorkspaceFromList(path: string): Promise<WorkspaceInfo> {
280
280
  return api(`/api/workspaces?path=${encodeURIComponent(path)}`, { method: 'DELETE' })
281
281
  }
282
282
 
283
- export type McpServerInput = Partial<McpServer> & { name: string }
283
+ export type { McpServerInput } from './mcp-config'
284
+ import type { McpServerInput } from './mcp-config'
284
285
 
285
286
  export function mcpList(): Promise<{ servers: McpServer[] }> {
286
287
  return api('/api/mcp')
@@ -294,6 +295,14 @@ export function mcpAction(id: string, action: 'connect' | 'disconnect' | 'refres
294
295
  return api(`/api/mcp/${encodeURIComponent(id)}/${action}`, { method: 'POST', body: '{}' })
295
296
  }
296
297
 
298
+ export function mcpAuthorize(id: string): Promise<{ url: string }> {
299
+ return api(`/api/mcp/${encodeURIComponent(id)}/authorize`, { method: 'POST', body: '{}' })
300
+ }
301
+
302
+ export function mcpLogout(id: string): Promise<{ servers: McpServer[] }> {
303
+ return api(`/api/mcp/${encodeURIComponent(id)}/logout`, { method: 'POST', body: '{}' })
304
+ }
305
+
297
306
  export function mcpCall(
298
307
  server: string,
299
308
  tool: string,
@@ -0,0 +1,47 @@
1
+ import assert from 'node:assert/strict'
2
+ import test from 'node:test'
3
+ import { draftFromServer, inputFromDraft, joinArgs, parseMcpJson, splitArgs } from './mcp-config.ts'
4
+
5
+ test('imports Claude Desktop style config with local and remote servers', () => {
6
+ const { servers, skipped } = parseMcpJson(
7
+ JSON.stringify({
8
+ mcpServers: {
9
+ files: { command: 'npx', args: ['-y', 'server-filesystem', '/my dir'], env: { TOKEN: 'x' } },
10
+ linear: { url: 'https://mcp.linear.app/mcp' },
11
+ legacy: { url: 'https://example.com/sse', type: 'sse' },
12
+ keyed: { url: 'https://example.com/mcp', headers: { Authorization: 'Bearer k' } },
13
+ broken: { nothing: true },
14
+ },
15
+ }),
16
+ )
17
+ assert.deepEqual(skipped, ['broken'])
18
+ const byName = Object.fromEntries(servers.map((server) => [server.name, server]))
19
+ assert.equal(byName.files.transport, 'stdio')
20
+ assert.deepEqual(byName.files.args, ['-y', 'server-filesystem', '/my dir'])
21
+ assert.equal(byName.linear.transport, 'http')
22
+ assert.equal(byName.linear.oauth?.enabled, true)
23
+ assert.equal(byName.legacy.transport, 'sse')
24
+ assert.equal(byName.keyed.oauth, null)
25
+ })
26
+
27
+ test('accepts a single server object and rejects junk', () => {
28
+ assert.equal(parseMcpJson('{"command":"node","args":["a.js"]}').servers[0].name, 'Imported')
29
+ assert.throws(() => parseMcpJson('nope'), /valid JSON/)
30
+ assert.throws(() => parseMcpJson('{"a":1}'), /No MCP servers/)
31
+ })
32
+
33
+ test('draft round-trip keeps secrets masked and quoting intact', () => {
34
+ assert.deepEqual(splitArgs('-y "my dir" x'), ['-y', 'my dir', 'x'])
35
+ assert.equal(joinArgs(['-y', 'my dir']), '-y "my dir"')
36
+ const server = {
37
+ id: 'a', name: 'A', transport: 'http', enabled: false, approval: 'auto', disabledTools: ['t'], status: 'connected',
38
+ error: '', serverInfo: null, connectedAt: null, tools: [], url: 'https://x.dev/mcp', headers: { 'X-Key': '••••••••' },
39
+ oauth: { enabled: true, clientId: 'cid', clientSecret: '••••••••', scope: 'read', authorized: true },
40
+ }
41
+ const input = inputFromDraft(draftFromServer(server), server)
42
+ assert.equal(input.enabled, false)
43
+ assert.equal(input.approval, 'auto')
44
+ assert.equal(input.headers['X-Key'], '••••••••')
45
+ assert.equal(input.oauth.clientSecret, '••••••••')
46
+ assert.deepEqual(input.disabledTools, ['t'])
47
+ })
@@ -0,0 +1,203 @@
1
+ // Pure helpers for the MCP panel: form <-> config conversion, presets and JSON import.
2
+ // Keep `import type` only here: node --test loads this file directly (see CLAUDE.md).
3
+ import type { McpServer, McpTransport } from '../types'
4
+
5
+ export interface McpServerInput {
6
+ id?: string
7
+ name: string
8
+ transport?: McpTransport
9
+ enabled?: boolean
10
+ approval?: 'auto' | 'ask'
11
+ disabledTools?: string[]
12
+ command?: string
13
+ args?: string[]
14
+ env?: Record<string, string>
15
+ url?: string
16
+ headers?: Record<string, string>
17
+ oauth?: { enabled: boolean; clientId?: string; clientSecret?: string; scope?: string } | null
18
+ }
19
+
20
+ export interface McpDraft {
21
+ /** Present when editing an existing server. */
22
+ id?: string
23
+ name: string
24
+ transport: McpTransport
25
+ command: string
26
+ args: string
27
+ env: string
28
+ url: string
29
+ headers: string
30
+ oauth: boolean
31
+ clientId: string
32
+ clientSecret: string
33
+ scope: string
34
+ }
35
+
36
+ export const EMPTY_DRAFT: McpDraft = {
37
+ name: '',
38
+ transport: 'stdio',
39
+ command: '',
40
+ args: '',
41
+ env: '',
42
+ url: '',
43
+ headers: '',
44
+ oauth: false,
45
+ clientId: '',
46
+ clientSecret: '',
47
+ scope: '',
48
+ }
49
+
50
+ export interface McpPreset {
51
+ label: string
52
+ description: string
53
+ draft: Partial<McpDraft>
54
+ }
55
+
56
+ export const MCP_PRESETS: McpPreset[] = [
57
+ { label: 'Filesystem', description: 'Read and write files in a folder', draft: { name: 'Filesystem', command: 'npx', args: '-y @modelcontextprotocol/server-filesystem .' } },
58
+ { label: 'Memory', description: 'Knowledge-graph memory', draft: { name: 'Memory', command: 'npx', args: '-y @modelcontextprotocol/server-memory' } },
59
+ { label: 'Fetch', description: 'Fetch web pages as markdown', draft: { name: 'Fetch', command: 'uvx', args: 'mcp-server-fetch' } },
60
+ { label: 'Linear', description: 'Issues and projects · OAuth', draft: { name: 'Linear', transport: 'http', url: 'https://mcp.linear.app/mcp', oauth: true } },
61
+ { label: 'Notion', description: 'Pages and databases · OAuth', draft: { name: 'Notion', transport: 'http', url: 'https://mcp.notion.com/mcp', oauth: true } },
62
+ { label: 'Sentry', description: 'Errors and traces · OAuth', draft: { name: 'Sentry', transport: 'http', url: 'https://mcp.sentry.dev/mcp', oauth: true } },
63
+ { label: 'Atlassian', description: 'Jira and Confluence · SSE + OAuth', draft: { name: 'Atlassian', transport: 'sse', url: 'https://mcp.atlassian.com/v1/sse', oauth: true } },
64
+ { label: 'DeepWiki', description: 'Repo docs · no sign-in', draft: { name: 'DeepWiki', transport: 'http', url: 'https://mcp.deepwiki.com/mcp' } },
65
+ ]
66
+
67
+ /** Shell-like split that keeps quoted segments together. */
68
+ export function splitArgs(value: string): string[] {
69
+ const out: string[] = []
70
+ const pattern = /"([^"]*)"|'([^']*)'|(\S+)/g
71
+ let match: RegExpExecArray | null
72
+ while ((match = pattern.exec(value))) out.push(match[1] ?? match[2] ?? match[3])
73
+ return out
74
+ }
75
+
76
+ export function joinArgs(args: string[] = []): string {
77
+ return args.map((arg) => (/\s/.test(arg) ? `"${arg}"` : arg)).join(' ')
78
+ }
79
+
80
+ export function parsePairs(value: string, separator: string): Record<string, string> {
81
+ const out: Record<string, string> = {}
82
+ for (const line of value.split('\n')) {
83
+ const index = line.indexOf(separator)
84
+ if (index < 1) continue
85
+ out[line.slice(0, index).trim()] = line.slice(index + 1).trim()
86
+ }
87
+ return out
88
+ }
89
+
90
+ export function formatPairs(record: Record<string, string> | undefined, separator: string): string {
91
+ return Object.entries(record ?? {})
92
+ .map(([key, value]) => `${key}${separator === ':' ? ': ' : separator}${value}`)
93
+ .join('\n')
94
+ }
95
+
96
+ export function draftFromServer(server: McpServer): McpDraft {
97
+ return {
98
+ id: server.id,
99
+ name: server.name,
100
+ transport: server.transport,
101
+ command: server.command ?? '',
102
+ args: joinArgs(server.args),
103
+ env: formatPairs(server.env, '='),
104
+ url: server.url ?? '',
105
+ headers: formatPairs(server.headers, ':'),
106
+ oauth: Boolean(server.oauth?.enabled),
107
+ clientId: server.oauth?.clientId ?? '',
108
+ clientSecret: server.oauth?.clientSecret ?? '',
109
+ scope: server.oauth?.scope ?? '',
110
+ }
111
+ }
112
+
113
+ export function draftIsValid(draft: McpDraft): boolean {
114
+ if (!draft.name.trim()) return false
115
+ if (draft.transport === 'stdio') return Boolean(draft.command.trim())
116
+ return /^https?:\/\//i.test(draft.url.trim())
117
+ }
118
+
119
+ /** Builds the payload for PUT /api/mcp. `previous` keeps flags the form does not edit. */
120
+ export function inputFromDraft(draft: McpDraft, previous?: McpServer): McpServerInput {
121
+ const base = {
122
+ ...(draft.id ? { id: draft.id } : {}),
123
+ name: draft.name.trim(),
124
+ transport: draft.transport,
125
+ enabled: previous?.enabled ?? true,
126
+ approval: previous?.approval ?? ('ask' as const),
127
+ disabledTools: previous?.disabledTools ?? [],
128
+ }
129
+ if (draft.transport === 'stdio') {
130
+ return { ...base, command: draft.command.trim(), args: splitArgs(draft.args), env: parsePairs(draft.env, '=') }
131
+ }
132
+ return {
133
+ ...base,
134
+ url: draft.url.trim(),
135
+ headers: parsePairs(draft.headers, ':'),
136
+ oauth: draft.oauth
137
+ ? { enabled: true, clientId: draft.clientId.trim(), clientSecret: draft.clientSecret.trim(), scope: draft.scope.trim() }
138
+ : null,
139
+ }
140
+ }
141
+
142
+ export function inputFromServer(server: McpServer): McpServerInput {
143
+ return inputFromDraft(draftFromServer(server), server)
144
+ }
145
+
146
+ type Json = Record<string, unknown>
147
+ const isObject = (value: unknown): value is Json => typeof value === 'object' && value !== null && !Array.isArray(value)
148
+ const strings = (value: unknown): Record<string, string> => {
149
+ const out: Record<string, string> = {}
150
+ if (isObject(value)) for (const [key, item] of Object.entries(value)) if (typeof item === 'string' || typeof item === 'number') out[key] = String(item)
151
+ return out
152
+ }
153
+
154
+ function entryToInput(name: string, raw: Json): McpServerInput | null {
155
+ const enabled = raw.disabled === true ? false : true
156
+ const url = [raw.url, raw.serverUrl, raw.httpUrl].find((item): item is string => typeof item === 'string')
157
+ if (url) {
158
+ const kind = String(raw.type ?? raw.transport ?? '').toLowerCase()
159
+ const transport: McpTransport = kind.includes('sse') || (!kind && /\/sse\/?$/i.test(url)) ? 'sse' : 'http'
160
+ const headers = strings(raw.headers)
161
+ const hasAuthHeader = Object.keys(headers).some((key) => key.toLowerCase() === 'authorization')
162
+ return { name, transport, enabled, url, headers, oauth: hasAuthHeader ? null : { enabled: true } }
163
+ }
164
+ if (typeof raw.command === 'string' && raw.command.trim()) {
165
+ const args = Array.isArray(raw.args) ? raw.args.filter((item): item is string => typeof item === 'string') : []
166
+ return { name, transport: 'stdio', enabled, command: raw.command, args, env: strings(raw.env) }
167
+ }
168
+ return null
169
+ }
170
+
171
+ const looksLikeServer = (value: unknown) => isObject(value) && (typeof value.command === 'string' || typeof value.url === 'string' || typeof value.serverUrl === 'string' || typeof value.httpUrl === 'string')
172
+
173
+ /** Accepts Claude Desktop / Cursor / VS Code style JSON ({ mcpServers | servers: {...} }) or one server object. */
174
+ export function parseMcpJson(text: string): { servers: McpServerInput[]; skipped: string[] } {
175
+ let data: unknown
176
+ try {
177
+ data = JSON.parse(text)
178
+ } catch {
179
+ throw new Error('That is not valid JSON.')
180
+ }
181
+ if (!isObject(data)) throw new Error('Expected a JSON object.')
182
+ let map: Json | null = null
183
+ for (const candidate of [data.mcpServers, data.servers, isObject(data.mcp) ? data.mcp.servers : undefined]) {
184
+ if (isObject(candidate)) {
185
+ map = candidate
186
+ break
187
+ }
188
+ }
189
+ if (!map) {
190
+ if (looksLikeServer(data)) map = { Imported: data }
191
+ else if (Object.values(data).length > 0 && Object.values(data).every(looksLikeServer)) map = data
192
+ }
193
+ if (!map) throw new Error('No MCP servers found. Expect {"mcpServers": {"name": {"command": …} or {"url": …}}}.')
194
+ const servers: McpServerInput[] = []
195
+ const skipped: string[] = []
196
+ for (const [name, raw] of Object.entries(map)) {
197
+ const input = isObject(raw) ? entryToInput(name.slice(0, 60), raw) : null
198
+ if (input) servers.push(input)
199
+ else skipped.push(name)
200
+ }
201
+ if (servers.length === 0) throw new Error('None of those entries has a "command" or a "url".')
202
+ return { servers, skipped }
203
+ }
package/src/store/mcp.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { create } from 'zustand'
2
2
  import type { McpServer, McpTool, McpToolName } from '../types'
3
- import { mcpAction, mcpList, mcpSave, type McpServerInput } from '../lib/daemon'
3
+ import { mcpAction, mcpAuthorize, mcpList, mcpLogout, mcpSave, type McpServerInput } from '../lib/daemon'
4
4
 
5
5
  interface McpState {
6
6
  servers: McpServer[]
@@ -10,6 +10,9 @@ interface McpState {
10
10
  load: () => Promise<void>
11
11
  save: (servers: McpServerInput[]) => Promise<void>
12
12
  act: (id: string, action: 'connect' | 'disconnect' | 'refresh') => Promise<void>
13
+ /** Opens the provider's consent page; resolves once the popup was opened. */
14
+ authorize: (id: string) => Promise<void>
15
+ logout: (id: string) => Promise<void>
13
16
  lookup: (fn: string) => { server: McpServer; tool: McpTool } | null
14
17
  }
15
18
 
@@ -52,6 +55,24 @@ export const useMcp = create<McpState>()((set, get) => {
52
55
  const result = await mcpAction(id, action)
53
56
  apply(result.servers)
54
57
  },
58
+ authorize: async (id) => {
59
+ const { url } = await mcpAuthorize(id)
60
+ const popup = window.open(url, 'opensol-mcp-oauth', 'popup=yes,width=520,height=720')
61
+ if (!popup) throw new Error('The browser blocked the sign-in window. Allow pop-ups for OpenSol and try again.')
62
+ // The callback page also broadcasts; polling covers browsers that drop BroadcastChannel.
63
+ const started = Date.now()
64
+ const watch = window.setInterval(() => {
65
+ void get().load()
66
+ const server = get().servers.find((item) => item.id === id)
67
+ if (popup.closed || server?.oauth?.authorized || Date.now() - started > 5 * 60_000) {
68
+ window.clearInterval(watch)
69
+ void get().load()
70
+ }
71
+ }, 1500)
72
+ },
73
+ logout: async (id) => {
74
+ apply((await mcpLogout(id)).servers)
75
+ },
55
76
  lookup: (fn) => {
56
77
  for (const server of get().servers) {
57
78
  const tool = server.tools.find((entry) => entry.fn === fn)
@@ -65,3 +86,9 @@ export const useMcp = create<McpState>()((set, get) => {
65
86
  export function isMcpToolName(name: string): name is McpToolName {
66
87
  return name.startsWith('mcp__')
67
88
  }
89
+
90
+ try {
91
+ new BroadcastChannel('opensol-mcp-oauth').onmessage = () => void useMcp.getState().load()
92
+ } catch {
93
+ // BroadcastChannel is optional; the authorize poll still refreshes the list.
94
+ }
@@ -25,7 +25,7 @@ import {
25
25
  import { defaultEffort, normalizeEffort } from '../lib/reasoning'
26
26
  import { normalizeTheme } from '../lib/themes'
27
27
 
28
- export const DEFAULT_SYSTEM_PROMPT = `You are OpenSol, a senior web engineer and coding agent built by OpenSoft.
28
+ export const DEFAULT_SYSTEM_PROMPT = `You are OpenSol, a senior web engineer and coding agent built by OpenSoftX.
29
29
 
30
30
  You work directly in the user's open folder through a local daemon. The shell, file, git, search and deploy tools are real. Never describe tool output you did not receive.
31
31
 
package/src/store/ui.ts CHANGED
@@ -55,6 +55,8 @@ interface UiState {
55
55
  setMobileSidebarOpen: (open: boolean) => void
56
56
  workspaceOpen: boolean
57
57
  setWorkspaceOpen: (open: boolean) => void
58
+ mcpOpen: boolean
59
+ setMcpOpen: (open: boolean) => void
58
60
  settingsOpen: boolean
59
61
  settingsTab:
60
62
  | 'general'
@@ -111,6 +113,8 @@ export const useUi = create<UiState>()((set, get) => ({
111
113
  setMobileSidebarOpen: (open) => set({ mobileSidebarOpen: open }),
112
114
  workspaceOpen: false,
113
115
  setWorkspaceOpen: (open) => set({ workspaceOpen: open }),
116
+ mcpOpen: false,
117
+ setMcpOpen: (open) => set({ mcpOpen: open, ...(open ? { mobileSidebarOpen: false, settingsOpen: false } : {}) }),
114
118
  settingsOpen: false,
115
119
  settingsTab: 'general',
116
120
  openSettings: (tab = 'general') => set({ settingsOpen: true, settingsTab: tab, mobileSidebarOpen: false }),
package/src/types.ts CHANGED
@@ -231,7 +231,18 @@ export interface ChatSession {
231
231
  titleSource?: 'auto' | 'ai' | 'user'
232
232
  }
233
233
 
234
- export type McpStatus = 'disabled' | 'disconnected' | 'connecting' | 'connected' | 'error'
234
+ export type McpStatus = 'disabled' | 'disconnected' | 'connecting' | 'connected' | 'needs_auth' | 'error'
235
+
236
+ export type McpTransport = 'stdio' | 'http' | 'sse'
237
+
238
+ export interface McpOAuth {
239
+ enabled: boolean
240
+ clientId: string
241
+ /** Masked by the daemon; send the mask back to keep the stored secret. */
242
+ clientSecret: string
243
+ scope: string
244
+ authorized: boolean
245
+ }
235
246
 
236
247
  export interface McpTool {
237
248
  name: string
@@ -244,7 +255,7 @@ export interface McpTool {
244
255
  export interface McpServer {
245
256
  id: string
246
257
  name: string
247
- transport: 'stdio' | 'http'
258
+ transport: McpTransport
248
259
  enabled: boolean
249
260
  approval: 'auto' | 'ask'
250
261
  disabledTools: string[]
@@ -258,6 +269,7 @@ export interface McpServer {
258
269
  env?: Record<string, string>
259
270
  url?: string
260
271
  headers?: Record<string, string>
272
+ oauth?: McpOAuth | null
261
273
  }
262
274
 
263
275
  export interface Usage {
@@ -1 +1 @@
1
- {"root":["./src/App.tsx","./src/main.tsx","./src/types.ts","./src/vite-env.d.ts","./src/agent/approval.ts","./src/agent/compact.ts","./src/agent/index.ts","./src/agent/model.ts","./src/agent/prompt.ts","./src/agent/queue.ts","./src/agent/subagent.ts","./src/agent/tools.ts","./src/agent/adapters/anthropic.ts","./src/agent/adapters/gemini.ts","./src/agent/adapters/openai-responses.ts","./src/agent/adapters/openai.ts","./src/agent/adapters/shared.ts","./src/components/ChatView.tsx","./src/components/CommandPalette.tsx","./src/components/Composer.tsx","./src/components/ContextBar.tsx","./src/components/DeployModal.tsx","./src/components/DiffView.tsx","./src/components/ErrorBoundary.tsx","./src/components/HomeDashboard.tsx","./src/components/Logo.tsx","./src/components/Markdown.tsx","./src/components/McpTab.tsx","./src/components/MessageItem.tsx","./src/components/ModelDeck.tsx","./src/components/PixleSol.tsx","./src/components/PluginBuilder.tsx","./src/components/PluginManager.tsx","./src/components/PreviewPane.tsx","./src/components/SessionsTab.tsx","./src/components/SettingsModal.tsx","./src/components/Sidebar.tsx","./src/components/Splash.tsx","./src/components/ToolCallCard.tsx","./src/components/TopBar.tsx","./src/components/TrajectoryView.tsx","./src/components/WorkspacePanel.tsx","./src/components/ui.tsx","./src/core/kernel.ts","./src/core/mcp-tools.ts","./src/core/types.ts","./src/core/plugins/tools/fs.ts","./src/core/plugins/tools/git.ts","./src/core/plugins/tools/grep.ts","./src/core/plugins/tools/shell.ts","./src/core/plugins/tools/subagent.ts","./src/core/plugins/tools/web.ts","./src/core/services/agent-loop.ts","./src/core/services/approval.ts","./src/core/services/compaction.ts","./src/core/services/llm.ts","./src/core/services/sessions.ts","./src/core/services/subagents.ts","./src/core/services/system-prompt.ts","./src/core/services/titles.ts","./src/core/services/tools.ts","./src/core/services/ui.ts","./src/data/providers.ts","./src/data/skills.ts","./src/lib/boot.ts","./src/lib/context.ts","./src/lib/conversation.ts","./src/lib/daemon.ts","./src/lib/diff.ts","./src/lib/hooks.ts","./src/lib/permissions.ts","./src/lib/pixlesol.ts","./src/lib/plugin-folder.ts","./src/lib/reasoning.ts","./src/lib/sse.ts","./src/lib/themes.ts","./src/lib/usage.ts","./src/lib/utils.ts","./src/plugins/api.ts","./src/plugins/bridge.d.mts","./src/plugins/index.ts","./src/plugins/preview.ts","./src/plugins/runtime.ts","./src/plugins/types.ts","./src/store/chats.ts","./src/store/mcp.ts","./src/store/models.ts","./src/store/settings.ts","./src/store/ui.ts","./src/store/workspace.ts"],"version":"5.9.3"}
1
+ {"root":["./src/App.tsx","./src/main.tsx","./src/types.ts","./src/vite-env.d.ts","./src/agent/approval.ts","./src/agent/compact.ts","./src/agent/index.ts","./src/agent/model.ts","./src/agent/prompt.ts","./src/agent/queue.ts","./src/agent/subagent.ts","./src/agent/tools.ts","./src/agent/adapters/anthropic.ts","./src/agent/adapters/gemini.ts","./src/agent/adapters/openai-responses.ts","./src/agent/adapters/openai.ts","./src/agent/adapters/shared.ts","./src/components/ChatView.tsx","./src/components/CommandPalette.tsx","./src/components/Composer.tsx","./src/components/ContextBar.tsx","./src/components/DeployModal.tsx","./src/components/DiffView.tsx","./src/components/ErrorBoundary.tsx","./src/components/HomeDashboard.tsx","./src/components/Logo.tsx","./src/components/Markdown.tsx","./src/components/McpPanel.tsx","./src/components/MessageItem.tsx","./src/components/ModelDeck.tsx","./src/components/PixleSol.tsx","./src/components/PluginBuilder.tsx","./src/components/PluginManager.tsx","./src/components/PreviewPane.tsx","./src/components/SessionsTab.tsx","./src/components/SettingsModal.tsx","./src/components/Sidebar.tsx","./src/components/Splash.tsx","./src/components/ToolCallCard.tsx","./src/components/TopBar.tsx","./src/components/TrajectoryView.tsx","./src/components/WorkspacePanel.tsx","./src/components/ui.tsx","./src/core/kernel.ts","./src/core/mcp-tools.ts","./src/core/types.ts","./src/core/plugins/tools/fs.ts","./src/core/plugins/tools/git.ts","./src/core/plugins/tools/grep.ts","./src/core/plugins/tools/shell.ts","./src/core/plugins/tools/subagent.ts","./src/core/plugins/tools/web.ts","./src/core/services/agent-loop.ts","./src/core/services/approval.ts","./src/core/services/compaction.ts","./src/core/services/llm.ts","./src/core/services/sessions.ts","./src/core/services/subagents.ts","./src/core/services/system-prompt.ts","./src/core/services/titles.ts","./src/core/services/tools.ts","./src/core/services/ui.ts","./src/data/providers.ts","./src/data/skills.ts","./src/lib/boot.ts","./src/lib/context.ts","./src/lib/conversation.ts","./src/lib/daemon.ts","./src/lib/diff.ts","./src/lib/hooks.ts","./src/lib/mcp-config.ts","./src/lib/permissions.ts","./src/lib/pixlesol.ts","./src/lib/plugin-folder.ts","./src/lib/reasoning.ts","./src/lib/sse.ts","./src/lib/themes.ts","./src/lib/usage.ts","./src/lib/utils.ts","./src/plugins/api.ts","./src/plugins/bridge.d.mts","./src/plugins/index.ts","./src/plugins/preview.ts","./src/plugins/runtime.ts","./src/plugins/types.ts","./src/store/chats.ts","./src/store/mcp.ts","./src/store/models.ts","./src/store/settings.ts","./src/store/ui.ts","./src/store/workspace.ts"],"version":"5.9.3"}