@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.
Files changed (83) hide show
  1. package/.github/workflows/build.yml +60 -8
  2. package/CHANGELOG.md +21 -0
  3. package/README.md +2 -2
  4. package/desktop/README.md +39 -0
  5. package/desktop/loading/index.html +36 -0
  6. package/desktop/src-tauri/Cargo.lock +4301 -0
  7. package/desktop/src-tauri/Cargo.toml +23 -0
  8. package/desktop/src-tauri/binaries/.gitkeep +0 -0
  9. package/desktop/src-tauri/build.rs +3 -0
  10. package/desktop/src-tauri/capabilities/default.json +7 -0
  11. package/desktop/src-tauri/icons/128x128.png +0 -0
  12. package/desktop/src-tauri/icons/128x128@2x.png +0 -0
  13. package/desktop/src-tauri/icons/32x32.png +0 -0
  14. package/desktop/src-tauri/icons/64x64.png +0 -0
  15. package/desktop/src-tauri/icons/Square107x107Logo.png +0 -0
  16. package/desktop/src-tauri/icons/Square142x142Logo.png +0 -0
  17. package/desktop/src-tauri/icons/Square150x150Logo.png +0 -0
  18. package/desktop/src-tauri/icons/Square284x284Logo.png +0 -0
  19. package/desktop/src-tauri/icons/Square30x30Logo.png +0 -0
  20. package/desktop/src-tauri/icons/Square310x310Logo.png +0 -0
  21. package/desktop/src-tauri/icons/Square44x44Logo.png +0 -0
  22. package/desktop/src-tauri/icons/Square71x71Logo.png +0 -0
  23. package/desktop/src-tauri/icons/Square89x89Logo.png +0 -0
  24. package/desktop/src-tauri/icons/StoreLogo.png +0 -0
  25. package/desktop/src-tauri/icons/icon.icns +0 -0
  26. package/desktop/src-tauri/icons/icon.ico +0 -0
  27. package/desktop/src-tauri/icons/icon.png +0 -0
  28. package/desktop/src-tauri/src/lib.rs +210 -0
  29. package/desktop/src-tauri/src/main.rs +6 -0
  30. package/desktop/src-tauri/tauri.conf.json +48 -0
  31. package/dist/assets/index-CeN54A2N.css +1 -0
  32. package/dist/assets/index-fhIO9a7_.js +318 -0
  33. package/dist/index.html +4 -4
  34. package/index.html +2 -2
  35. package/package.json +2 -1
  36. package/server/README.md +2 -2
  37. package/server/daemon.mjs +138 -2
  38. package/server/lib/mcp-oauth.mjs +219 -0
  39. package/server/lib/mcp-oauth.test.mjs +175 -0
  40. package/server/lib/mcp.mjs +899 -0
  41. package/server/lib/mcp.test.mjs +46 -0
  42. package/server/lib/store.mjs +22 -0
  43. package/server/lib/tools.mjs +59 -1
  44. package/src/App.tsx +2 -0
  45. package/src/agent/tools.ts +6 -5
  46. package/src/components/CommandPalette.tsx +14 -1
  47. package/src/components/Composer.tsx +22 -38
  48. package/src/components/DiffView.tsx +98 -0
  49. package/src/components/Logo.tsx +1 -1
  50. package/src/components/McpPanel.tsx +687 -0
  51. package/src/components/MessageItem.tsx +62 -13
  52. package/src/components/ModelDeck.tsx +661 -0
  53. package/src/components/SessionsTab.tsx +206 -0
  54. package/src/components/SettingsModal.tsx +146 -71
  55. package/src/components/Sidebar.tsx +79 -24
  56. package/src/components/Splash.tsx +1 -1
  57. package/src/components/ToolCallCard.tsx +29 -7
  58. package/src/components/WorkspacePanel.tsx +16 -1
  59. package/src/core/kernel.ts +2 -0
  60. package/src/core/mcp-tools.ts +63 -0
  61. package/src/core/plugins/tools/fs.ts +24 -3
  62. package/src/core/services/agent-loop.ts +3 -1
  63. package/src/core/services/approval.ts +2 -1
  64. package/src/core/services/titles.ts +135 -0
  65. package/src/core/services/tools.ts +11 -8
  66. package/src/core/types.ts +4 -0
  67. package/src/data/providers.ts +1 -1
  68. package/src/index.css +97 -1
  69. package/src/lib/daemon.ts +38 -0
  70. package/src/lib/diff.ts +62 -0
  71. package/src/lib/mcp-config.test.mjs +47 -0
  72. package/src/lib/mcp-config.ts +203 -0
  73. package/src/lib/permissions.ts +6 -6
  74. package/src/store/chats.ts +9 -1
  75. package/src/store/mcp.ts +94 -0
  76. package/src/store/settings.ts +10 -7
  77. package/src/store/ui.ts +17 -0
  78. package/src/store/workspace.ts +26 -0
  79. package/src/types.ts +63 -2
  80. package/tsconfig.tsbuildinfo +1 -1
  81. package/dist/assets/index-BmN7pBji.css +0 -1
  82. package/dist/assets/index-DgNDjkOz.js +0 -301
  83. 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
- for (const def of this.definitions.values()) {
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.tools[def.name]?.enabled) continue
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.tools[name]?.enabled) {
87
- return { decision: 'deny', reason: 'This tool is disabled in Settings → Tools.' }
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.definitions.get(name)
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.definitions.get(name)
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 {
@@ -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/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-line py-4 last:border-b-0 sm:flex-row sm:items-center sm:justify-between sm:gap-6;
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)}`,
@@ -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
+ }
@@ -1,4 +1,4 @@
1
- import type { AppSettings, ToolName, ToolSetting } from '../types'
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: ToolName[] = ['write_file', 'edit_file', 'rewrite_file']
24
- const GUARDED_TOOLS: ToolName[] = ['shell', 'git', 'subagent']
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<ToolName, ToolSetting> {
38
- const next = {} as Record<ToolName, ToolSetting>
39
- for (const key of Object.keys(tools) as ToolName[]) {
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'
@@ -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) => {