@bibobonking/opensol 3.0.3 → 3.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/build.yml +60 -8
- package/CHANGELOG.md +21 -0
- package/README.md +2 -2
- package/desktop/README.md +39 -0
- package/desktop/loading/index.html +36 -0
- package/desktop/src-tauri/Cargo.lock +4301 -0
- package/desktop/src-tauri/Cargo.toml +23 -0
- package/desktop/src-tauri/binaries/.gitkeep +0 -0
- package/desktop/src-tauri/build.rs +3 -0
- package/desktop/src-tauri/capabilities/default.json +7 -0
- package/desktop/src-tauri/icons/128x128.png +0 -0
- package/desktop/src-tauri/icons/128x128@2x.png +0 -0
- package/desktop/src-tauri/icons/32x32.png +0 -0
- package/desktop/src-tauri/icons/64x64.png +0 -0
- package/desktop/src-tauri/icons/Square107x107Logo.png +0 -0
- package/desktop/src-tauri/icons/Square142x142Logo.png +0 -0
- package/desktop/src-tauri/icons/Square150x150Logo.png +0 -0
- package/desktop/src-tauri/icons/Square284x284Logo.png +0 -0
- package/desktop/src-tauri/icons/Square30x30Logo.png +0 -0
- package/desktop/src-tauri/icons/Square310x310Logo.png +0 -0
- package/desktop/src-tauri/icons/Square44x44Logo.png +0 -0
- package/desktop/src-tauri/icons/Square71x71Logo.png +0 -0
- package/desktop/src-tauri/icons/Square89x89Logo.png +0 -0
- package/desktop/src-tauri/icons/StoreLogo.png +0 -0
- package/desktop/src-tauri/icons/icon.icns +0 -0
- package/desktop/src-tauri/icons/icon.ico +0 -0
- package/desktop/src-tauri/icons/icon.png +0 -0
- package/desktop/src-tauri/src/lib.rs +210 -0
- package/desktop/src-tauri/src/main.rs +6 -0
- package/desktop/src-tauri/tauri.conf.json +48 -0
- package/dist/assets/index-CeN54A2N.css +1 -0
- package/dist/assets/index-fhIO9a7_.js +318 -0
- package/dist/index.html +4 -4
- package/index.html +2 -2
- package/package.json +2 -1
- package/server/README.md +2 -2
- package/server/daemon.mjs +138 -2
- package/server/lib/mcp-oauth.mjs +219 -0
- package/server/lib/mcp-oauth.test.mjs +175 -0
- package/server/lib/mcp.mjs +899 -0
- package/server/lib/mcp.test.mjs +46 -0
- package/server/lib/store.mjs +22 -0
- package/server/lib/tools.mjs +59 -1
- package/src/App.tsx +2 -0
- package/src/agent/tools.ts +6 -5
- package/src/components/CommandPalette.tsx +14 -1
- package/src/components/Composer.tsx +22 -38
- package/src/components/DiffView.tsx +98 -0
- package/src/components/Logo.tsx +1 -1
- package/src/components/McpPanel.tsx +687 -0
- package/src/components/MessageItem.tsx +62 -13
- package/src/components/ModelDeck.tsx +661 -0
- package/src/components/SessionsTab.tsx +206 -0
- package/src/components/SettingsModal.tsx +146 -71
- package/src/components/Sidebar.tsx +79 -24
- package/src/components/Splash.tsx +1 -1
- package/src/components/ToolCallCard.tsx +29 -7
- package/src/components/WorkspacePanel.tsx +16 -1
- package/src/core/kernel.ts +2 -0
- package/src/core/mcp-tools.ts +63 -0
- package/src/core/plugins/tools/fs.ts +24 -3
- package/src/core/services/agent-loop.ts +3 -1
- package/src/core/services/approval.ts +2 -1
- package/src/core/services/titles.ts +135 -0
- package/src/core/services/tools.ts +11 -8
- package/src/core/types.ts +4 -0
- package/src/data/providers.ts +1 -1
- package/src/index.css +97 -1
- package/src/lib/daemon.ts +38 -0
- package/src/lib/diff.ts +62 -0
- package/src/lib/mcp-config.test.mjs +47 -0
- package/src/lib/mcp-config.ts +203 -0
- package/src/lib/permissions.ts +6 -6
- package/src/store/chats.ts +9 -1
- package/src/store/mcp.ts +94 -0
- package/src/store/settings.ts +10 -7
- package/src/store/ui.ts +17 -0
- package/src/store/workspace.ts +26 -0
- package/src/types.ts +63 -2
- package/tsconfig.tsbuildinfo +1 -1
- package/dist/assets/index-BmN7pBji.css +0 -1
- package/dist/assets/index-DgNDjkOz.js +0 -301
- package/src/components/ModelPicker.tsx +0 -487
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Service, type Context } from 'cordis'
|
|
2
2
|
import type { AgentMode, AppSettings, ToolName } from '../../types'
|
|
3
|
+
import { mcpDefinition, mcpDefinitions, resolveToolSetting } from '../mcp-tools'
|
|
3
4
|
import type {
|
|
4
5
|
ToolDefinition,
|
|
5
6
|
ToolExecutionContext,
|
|
@@ -39,11 +40,11 @@ export class ToolService extends Service {
|
|
|
39
40
|
}
|
|
40
41
|
|
|
41
42
|
get(name: ToolName): ToolDefinition | undefined {
|
|
42
|
-
return this.definitions.get(name)
|
|
43
|
+
return this.definitions.get(name) ?? mcpDefinition(name)
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
list(): ToolDefinition[] {
|
|
46
|
-
return [...this.definitions.values()]
|
|
47
|
+
return [...this.definitions.values(), ...mcpDefinitions()]
|
|
47
48
|
}
|
|
48
49
|
|
|
49
50
|
schemas(
|
|
@@ -52,9 +53,11 @@ export class ToolService extends Service {
|
|
|
52
53
|
{ subagent = false }: { subagent?: boolean } = {},
|
|
53
54
|
): ToolSchema[] {
|
|
54
55
|
const list: ToolSchema[] = []
|
|
55
|
-
|
|
56
|
+
// Subagents stay on the built-in read-only tools; MCP tools are not offered to them.
|
|
57
|
+
const all = subagent ? [...this.definitions.values()] : this.list()
|
|
58
|
+
for (const def of all) {
|
|
56
59
|
if (def.name === 'subagent' && subagent) continue
|
|
57
|
-
if (!settings
|
|
60
|
+
if (!resolveToolSetting(settings, def.name)?.enabled) continue
|
|
58
61
|
const allowed = def.allowedInMode ? def.allowedInMode(mode) : toolAllowedInMode(def.name, mode)
|
|
59
62
|
if (!allowed) continue
|
|
60
63
|
if ((def.name === 'web_search' || def.name === 'read_url') && !settings.webSearchEnabled) continue
|
|
@@ -83,10 +86,10 @@ export class ToolService extends Service {
|
|
|
83
86
|
'tools/pre-execute',
|
|
84
87
|
exec,
|
|
85
88
|
async () => {
|
|
86
|
-
if (!settings
|
|
87
|
-
return { decision: 'deny', reason: 'This tool is disabled
|
|
89
|
+
if (!resolveToolSetting(settings, name)?.enabled) {
|
|
90
|
+
return { decision: 'deny', reason: 'This tool is disabled or its MCP server is not connected (Settings → Tools / MCP).' }
|
|
88
91
|
}
|
|
89
|
-
const def = this.
|
|
92
|
+
const def = this.get(name)
|
|
90
93
|
const allowed = def?.allowedInMode ? def.allowedInMode(mode) : toolAllowedInMode(name, mode)
|
|
91
94
|
if (!allowed) {
|
|
92
95
|
return {
|
|
@@ -113,7 +116,7 @@ export class ToolService extends Service {
|
|
|
113
116
|
|
|
114
117
|
// 2. tools/execute waterfall
|
|
115
118
|
const result: ToolResult = await this.ctx.events.waterfall('tools/execute', exec, async () => {
|
|
116
|
-
const def = this.
|
|
119
|
+
const def = this.get(name)
|
|
117
120
|
if (!def) {
|
|
118
121
|
return { output: `Tool "${name}" is not registered.`, isError: true }
|
|
119
122
|
}
|
package/src/core/types.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type {
|
|
|
2
2
|
AgentMode,
|
|
3
3
|
AppSettings,
|
|
4
4
|
AssistantMessage,
|
|
5
|
+
FileDiff,
|
|
5
6
|
Message,
|
|
6
7
|
ModelInfo,
|
|
7
8
|
ToolCall,
|
|
@@ -32,6 +33,8 @@ export interface ToolSchema {
|
|
|
32
33
|
export interface ToolResult {
|
|
33
34
|
output: string
|
|
34
35
|
isError: boolean
|
|
36
|
+
/** Set by file tools so the UI can show +added / −removed lines. */
|
|
37
|
+
diff?: FileDiff
|
|
35
38
|
}
|
|
36
39
|
|
|
37
40
|
export interface ToolExecutionContext {
|
|
@@ -120,6 +123,7 @@ declare module 'cordis' {
|
|
|
120
123
|
subagents: import('./services/subagents').SubagentService
|
|
121
124
|
agentLoop: import('./services/agent-loop').AgentLoopService
|
|
122
125
|
ui: import('./services/ui').UiService
|
|
126
|
+
titles: import('./services/titles').TitleService
|
|
123
127
|
}
|
|
124
128
|
|
|
125
129
|
interface Events {
|
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/index.css
CHANGED
|
@@ -222,7 +222,15 @@
|
|
|
222
222
|
}
|
|
223
223
|
|
|
224
224
|
.settings-row {
|
|
225
|
-
@apply flex flex-col gap-2 border-b border-
|
|
225
|
+
@apply -mx-3 flex flex-col gap-2 rounded-2xl border-b border-transparent px-3 py-4 transition-colors duration-200 hover:bg-raised/50 sm:flex-row sm:items-center sm:justify-between sm:gap-6;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
.settings-card {
|
|
229
|
+
@apply relative rounded-2xl border border-line bg-panel p-4 shadow-pill transition-all duration-300 sm:p-5;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
.settings-card:hover {
|
|
233
|
+
border-color: rgb(var(--accent) / 0.25);
|
|
226
234
|
}
|
|
227
235
|
|
|
228
236
|
.settings-label {
|
|
@@ -774,3 +782,91 @@
|
|
|
774
782
|
33% { transform: translateX(0); }
|
|
775
783
|
66% { transform: translateX(1px); }
|
|
776
784
|
}
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
/* ───────── v3.1 motion & animation ───────── */
|
|
788
|
+
|
|
789
|
+
.settings-enter > * {
|
|
790
|
+
animation: rise 0.55s cubic-bezier(0.16, 1, 0.3, 1) both;
|
|
791
|
+
}
|
|
792
|
+
.settings-enter > *:nth-child(2) { animation-delay: 60ms; }
|
|
793
|
+
.settings-enter > *:nth-child(3) { animation-delay: 120ms; }
|
|
794
|
+
.settings-enter > *:nth-child(4) { animation-delay: 180ms; }
|
|
795
|
+
.settings-enter > *:nth-child(n + 5) { animation-delay: 240ms; }
|
|
796
|
+
|
|
797
|
+
.settings-title-in {
|
|
798
|
+
animation: blurin 0.55s cubic-bezier(0.16, 1, 0.3, 1) both;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
.settings-shell {
|
|
802
|
+
animation: shell-in 0.42s cubic-bezier(0.16, 1, 0.3, 1) backwards;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
.settings-glow {
|
|
806
|
+
background:
|
|
807
|
+
radial-gradient(120% 100% at 0% 0%, rgb(var(--accent) / 0.16), transparent 65%),
|
|
808
|
+
radial-gradient(80% 80% at 100% 0%, rgb(var(--accent) / 0.06), transparent 70%);
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
@keyframes shell-in {
|
|
812
|
+
from { opacity: 0; transform: translateY(18px) scale(0.975); filter: blur(6px); }
|
|
813
|
+
to { opacity: 1; transform: none; filter: blur(0); }
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/* Model deck */
|
|
817
|
+
.deck-scrim { animation: fade 0.25s ease-out both; }
|
|
818
|
+
.deck-panel { animation: deck-in 0.5s cubic-bezier(0.16, 1, 0.3, 1) backwards; }
|
|
819
|
+
.deck-row { animation: deck-row 0.4s cubic-bezier(0.16, 1, 0.3, 1) backwards; }
|
|
820
|
+
.deck-pop { animation: scalein 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) both; }
|
|
821
|
+
.deck-aurora {
|
|
822
|
+
background:
|
|
823
|
+
radial-gradient(70% 120% at 12% 0%, rgb(var(--accent) / 0.18), transparent 70%),
|
|
824
|
+
radial-gradient(50% 100% at 95% 0%, rgb(var(--accent) / 0.07), transparent 70%);
|
|
825
|
+
}
|
|
826
|
+
.deck-throttle {
|
|
827
|
+
background-image: radial-gradient(rgb(var(--line)) 1px, transparent 1px);
|
|
828
|
+
background-size: 14px 14px;
|
|
829
|
+
background-color: rgb(var(--canvas) / 0.7);
|
|
830
|
+
}
|
|
831
|
+
.throttle-knob { box-shadow: 0 0 0 4px rgb(var(--accent) / 0.16), 0 6px 18px -6px rgb(var(--accent) / 0.55); }
|
|
832
|
+
.throttle-knob::after {
|
|
833
|
+
content: '';
|
|
834
|
+
position: absolute;
|
|
835
|
+
inset: -6px;
|
|
836
|
+
border-radius: 9999px;
|
|
837
|
+
border: 1px solid rgb(var(--accent) / 0.5);
|
|
838
|
+
animation: ping 2.4s cubic-bezier(0, 0, 0.2, 1) infinite;
|
|
839
|
+
}
|
|
840
|
+
.deck-trigger:active { transform: scale(0.96); }
|
|
841
|
+
|
|
842
|
+
@keyframes deck-in {
|
|
843
|
+
from { opacity: 0; transform: translateY(24px) scale(0.96); filter: blur(8px); }
|
|
844
|
+
to { opacity: 1; transform: none; filter: blur(0); }
|
|
845
|
+
}
|
|
846
|
+
@keyframes deck-row {
|
|
847
|
+
from { opacity: 0; transform: translateX(-10px); }
|
|
848
|
+
to { opacity: 1; transform: none; }
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/* Diff rows slide in as a tool card opens. */
|
|
852
|
+
.diff-row { animation: deck-row 0.35s cubic-bezier(0.16, 1, 0.3, 1) backwards; }
|
|
853
|
+
.diff-badge { animation: scalein 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) both; }
|
|
854
|
+
|
|
855
|
+
/* Messages and tool rows arrive with a soft rise. */
|
|
856
|
+
.msg-enter { animation: rise 0.5s cubic-bezier(0.16, 1, 0.3, 1) both; }
|
|
857
|
+
|
|
858
|
+
/* Buttons feel physical. */
|
|
859
|
+
button:not(:disabled) { -webkit-tap-highlight-color: transparent; }
|
|
860
|
+
.chip, .nav-item, .sidebar-item { will-change: transform; }
|
|
861
|
+
|
|
862
|
+
/* Streaming caret and send-button pulse while the agent works. */
|
|
863
|
+
.session-card:hover { transform: translateX(2px); }
|
|
864
|
+
|
|
865
|
+
@media (prefers-reduced-motion: reduce) {
|
|
866
|
+
*, *::before, *::after {
|
|
867
|
+
animation-duration: 0.001ms !important;
|
|
868
|
+
animation-iteration-count: 1 !important;
|
|
869
|
+
transition-duration: 0.001ms !important;
|
|
870
|
+
scroll-behavior: auto !important;
|
|
871
|
+
}
|
|
872
|
+
}
|
package/src/lib/daemon.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type {
|
|
|
7
7
|
DeployProviderId,
|
|
8
8
|
DeployRequest,
|
|
9
9
|
DeployResult,
|
|
10
|
+
McpServer,
|
|
10
11
|
ModelInfo,
|
|
11
12
|
ProviderId,
|
|
12
13
|
ProviderStatus,
|
|
@@ -274,6 +275,43 @@ export function forgetWorkspace(): Promise<WorkspaceInfo> {
|
|
|
274
275
|
return api('/api/workspaces', { method: 'DELETE' })
|
|
275
276
|
}
|
|
276
277
|
|
|
278
|
+
/** Remove one folder from the saved list. The folder stays on disk. */
|
|
279
|
+
export function removeWorkspaceFromList(path: string): Promise<WorkspaceInfo> {
|
|
280
|
+
return api(`/api/workspaces?path=${encodeURIComponent(path)}`, { method: 'DELETE' })
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export type { McpServerInput } from './mcp-config'
|
|
284
|
+
import type { McpServerInput } from './mcp-config'
|
|
285
|
+
|
|
286
|
+
export function mcpList(): Promise<{ servers: McpServer[] }> {
|
|
287
|
+
return api('/api/mcp')
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export function mcpSave(servers: McpServerInput[]): Promise<{ servers: McpServer[] }> {
|
|
291
|
+
return api('/api/mcp', { method: 'PUT', body: JSON.stringify({ servers }) })
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export function mcpAction(id: string, action: 'connect' | 'disconnect' | 'refresh'): Promise<{ servers: McpServer[] }> {
|
|
295
|
+
return api(`/api/mcp/${encodeURIComponent(id)}/${action}`, { method: 'POST', body: '{}' })
|
|
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
|
+
|
|
306
|
+
export function mcpCall(
|
|
307
|
+
server: string,
|
|
308
|
+
tool: string,
|
|
309
|
+
args: Record<string, unknown>,
|
|
310
|
+
signal?: AbortSignal,
|
|
311
|
+
): Promise<{ result: { text: string; isError: boolean } }> {
|
|
312
|
+
return api('/api/mcp/call', { method: 'POST', body: JSON.stringify({ server, tool, args }), signal })
|
|
313
|
+
}
|
|
314
|
+
|
|
277
315
|
export function browseFolders(path: string) {
|
|
278
316
|
return api<{ path: string; parent: string; home: string; entries: Array<{ name: string; path: string }> }>(
|
|
279
317
|
`/api/workspaces/browse?path=${encodeURIComponent(path)}`,
|
package/src/lib/diff.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
export interface DiffOp {
|
|
2
|
+
type: 'ctx' | 'add' | 'del'
|
|
3
|
+
text: string
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/** Line diff via LCS on the changed middle; common head and tail are trimmed first. */
|
|
7
|
+
export function diffLines(before: string, after: string): DiffOp[] {
|
|
8
|
+
const a = before === '' ? [] : before.split('\n')
|
|
9
|
+
const b = after === '' ? [] : after.split('\n')
|
|
10
|
+
let start = 0
|
|
11
|
+
while (start < a.length && start < b.length && a[start] === b[start]) start += 1
|
|
12
|
+
let endA = a.length
|
|
13
|
+
let endB = b.length
|
|
14
|
+
while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) {
|
|
15
|
+
endA -= 1
|
|
16
|
+
endB -= 1
|
|
17
|
+
}
|
|
18
|
+
const x = a.slice(start, endA)
|
|
19
|
+
const y = b.slice(start, endB)
|
|
20
|
+
const ops: DiffOp[] = a.slice(0, start).map((text) => ({ type: 'ctx', text }))
|
|
21
|
+
|
|
22
|
+
if (x.length * y.length > 2_000_000) {
|
|
23
|
+
x.forEach((text) => ops.push({ type: 'del', text }))
|
|
24
|
+
y.forEach((text) => ops.push({ type: 'add', text }))
|
|
25
|
+
} else {
|
|
26
|
+
const table: Uint32Array[] = Array.from({ length: x.length + 1 }, () => new Uint32Array(y.length + 1))
|
|
27
|
+
for (let i = x.length - 1; i >= 0; i -= 1) {
|
|
28
|
+
for (let j = y.length - 1; j >= 0; j -= 1) {
|
|
29
|
+
table[i][j] = x[i] === y[j] ? table[i + 1][j + 1] + 1 : Math.max(table[i + 1][j], table[i][j + 1])
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
let i = 0
|
|
33
|
+
let j = 0
|
|
34
|
+
while (i < x.length && j < y.length) {
|
|
35
|
+
if (x[i] === y[j]) {
|
|
36
|
+
ops.push({ type: 'ctx', text: x[i] })
|
|
37
|
+
i += 1
|
|
38
|
+
j += 1
|
|
39
|
+
} else if (table[i + 1][j] >= table[i][j + 1]) {
|
|
40
|
+
ops.push({ type: 'del', text: x[i] })
|
|
41
|
+
i += 1
|
|
42
|
+
} else {
|
|
43
|
+
ops.push({ type: 'add', text: y[j] })
|
|
44
|
+
j += 1
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
while (i < x.length) ops.push({ type: 'del', text: x[i++] })
|
|
48
|
+
while (j < y.length) ops.push({ type: 'add', text: y[j++] })
|
|
49
|
+
}
|
|
50
|
+
a.slice(endA).forEach((text) => ops.push({ type: 'ctx', text }))
|
|
51
|
+
return ops
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function diffStats(ops: DiffOp[]) {
|
|
55
|
+
let added = 0
|
|
56
|
+
let removed = 0
|
|
57
|
+
for (const op of ops) {
|
|
58
|
+
if (op.type === 'add') added += 1
|
|
59
|
+
else if (op.type === 'del') removed += 1
|
|
60
|
+
}
|
|
61
|
+
return { added, removed }
|
|
62
|
+
}
|
|
@@ -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/lib/permissions.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AppSettings,
|
|
1
|
+
import type { AppSettings, BuiltinToolName, ToolSetting } from '../types'
|
|
2
2
|
|
|
3
3
|
export type PermissionMode = 'ask' | 'accept-edits' | 'full'
|
|
4
4
|
|
|
@@ -20,8 +20,8 @@ export const PERMISSION_MODES: Array<{ value: PermissionMode; label: string; des
|
|
|
20
20
|
},
|
|
21
21
|
]
|
|
22
22
|
|
|
23
|
-
const WRITE_TOOLS:
|
|
24
|
-
const GUARDED_TOOLS:
|
|
23
|
+
const WRITE_TOOLS: BuiltinToolName[] = ['write_file', 'edit_file', 'rewrite_file']
|
|
24
|
+
const GUARDED_TOOLS: BuiltinToolName[] = ['shell', 'git', 'subagent']
|
|
25
25
|
|
|
26
26
|
export function permissionMode(tools: AppSettings['tools']): PermissionMode {
|
|
27
27
|
const writesAuto = WRITE_TOOLS.every((tool) => tools[tool]?.approval === 'auto')
|
|
@@ -34,9 +34,9 @@ export function permissionMode(tools: AppSettings['tools']): PermissionMode {
|
|
|
34
34
|
export function permissionTools(
|
|
35
35
|
tools: AppSettings['tools'],
|
|
36
36
|
mode: PermissionMode,
|
|
37
|
-
): Record<
|
|
38
|
-
const next = {} as Record<
|
|
39
|
-
for (const key of Object.keys(tools) as
|
|
37
|
+
): Record<BuiltinToolName, ToolSetting> {
|
|
38
|
+
const next = {} as Record<BuiltinToolName, ToolSetting>
|
|
39
|
+
for (const key of Object.keys(tools) as BuiltinToolName[]) {
|
|
40
40
|
const mutating = WRITE_TOOLS.includes(key) || GUARDED_TOOLS.includes(key)
|
|
41
41
|
const approval: ToolSetting['approval'] =
|
|
42
42
|
mode === 'full'
|
package/src/store/chats.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { create } from 'zustand'
|
|
2
2
|
import type { AgentMode, ChatSession, Message, ProviderId } from '../types'
|
|
3
3
|
import { csrfToken, saveChats } from '../lib/daemon'
|
|
4
|
+
import { useWorkspace } from './workspace'
|
|
4
5
|
|
|
5
6
|
function createId(prefix: string) {
|
|
6
7
|
const raw =
|
|
@@ -46,6 +47,11 @@ function normalizeSession(session: ChatSession): ChatSession {
|
|
|
46
47
|
createdAt: typeof session.createdAt === 'number' ? session.createdAt : Date.now(),
|
|
47
48
|
updatedAt: typeof session.updatedAt === 'number' ? session.updatedAt : Date.now(),
|
|
48
49
|
mode: session.mode === 'plan' ? 'plan' : session.mode === 'build' ? 'build' : undefined,
|
|
50
|
+
workspace: typeof session.workspace === 'string' ? session.workspace : undefined,
|
|
51
|
+
titleSource:
|
|
52
|
+
session.titleSource === 'ai' || session.titleSource === 'user' || session.titleSource === 'auto'
|
|
53
|
+
? session.titleSource
|
|
54
|
+
: undefined,
|
|
49
55
|
}
|
|
50
56
|
}
|
|
51
57
|
|
|
@@ -110,6 +116,8 @@ export const useChats = create<ChatsState>()((set, get) => ({
|
|
|
110
116
|
provider,
|
|
111
117
|
model,
|
|
112
118
|
mode,
|
|
119
|
+
workspace: useWorkspace.getState().info.root || undefined,
|
|
120
|
+
titleSource: 'auto',
|
|
113
121
|
}
|
|
114
122
|
set((state) => ({ sessions: [session, ...state.sessions], activeId: id }))
|
|
115
123
|
return id
|
|
@@ -155,7 +163,7 @@ export const useChats = create<ChatsState>()((set, get) => ({
|
|
|
155
163
|
renameSession: (sessionId, title) => {
|
|
156
164
|
const clean = title.trim()
|
|
157
165
|
if (!clean) return
|
|
158
|
-
get().updateSession(sessionId, { title: clean })
|
|
166
|
+
get().updateSession(sessionId, { title: clean, titleSource: 'user' })
|
|
159
167
|
},
|
|
160
168
|
deleteSession: (sessionId) => {
|
|
161
169
|
set((state) => {
|