@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
|
@@ -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: '
|
|
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:
|
|
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 {
|
|
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: '
|
|
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">
|
|
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
|
|
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' && <
|
|
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
|
-
|
|
259
|
+
setMcpOpen(true)
|
|
259
260
|
}}
|
|
260
261
|
className="nav-item"
|
|
261
262
|
>
|
package/src/data/providers.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
+
}
|
package/src/store/settings.ts
CHANGED
|
@@ -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
|
|
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:
|
|
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 {
|
package/tsconfig.tsbuildinfo
CHANGED
|
@@ -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/
|
|
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"}
|