@swarmclawai/swarmclaw 1.5.63 → 1.5.65

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 (35) hide show
  1. package/README.md +28 -0
  2. package/package.json +2 -2
  3. package/src/app/api/chats/[id]/clear/route.ts +7 -3
  4. package/src/app/api/chats/[id]/clear/undo/route.ts +23 -0
  5. package/src/app/api/chats/[id]/compact/route.ts +72 -0
  6. package/src/app/api/chats/[id]/context-status/route.ts +21 -0
  7. package/src/app/api/chats/clear-route.test.ts +121 -0
  8. package/src/app/api/chats/compact-route.test.ts +70 -0
  9. package/src/app/api/chats/context-status-route.test.ts +68 -0
  10. package/src/app/api/mcp-registry/[slug]/route.ts +31 -0
  11. package/src/app/api/mcp-registry/route.ts +36 -0
  12. package/src/app/api/mcp-servers/[id]/route.ts +5 -0
  13. package/src/app/api/mcp-servers/[id]/test/route.ts +12 -1
  14. package/src/app/api/mcp-servers/[id]/tools-info/route.ts +75 -0
  15. package/src/app/api/mcp-servers/route.test.ts +10 -0
  16. package/src/cli/index.js +13 -1
  17. package/src/cli/spec.js +4 -1
  18. package/src/components/chat/chat-area.tsx +62 -6
  19. package/src/components/chat/chat-header.tsx +13 -1
  20. package/src/components/chat/context-meter-badge.tsx +227 -0
  21. package/src/components/mcp-servers/mcp-server-list.tsx +57 -1
  22. package/src/components/mcp-servers/mcp-server-sheet.tsx +202 -1
  23. package/src/components/mcp-servers/registry-browser.tsx +219 -0
  24. package/src/lib/chat/chats.ts +37 -1
  25. package/src/lib/server/chats/chat-session-service.ts +75 -0
  26. package/src/lib/server/chats/clear-undo-snapshots.test.ts +107 -0
  27. package/src/lib/server/chats/clear-undo-snapshots.ts +92 -0
  28. package/src/lib/server/mcp-connection-pool.test.ts +127 -0
  29. package/src/lib/server/mcp-connection-pool.ts +150 -0
  30. package/src/lib/server/mcp-gateway-runtime.test.ts +177 -0
  31. package/src/lib/server/mcp-gateway-runtime.ts +138 -0
  32. package/src/lib/server/session-tools/index.ts +114 -15
  33. package/src/lib/server/storage-normalization.ts +11 -0
  34. package/src/types/agent.ts +1 -0
  35. package/src/types/misc.ts +7 -0
@@ -0,0 +1,75 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { loadMcpServers } from '@/lib/server/storage'
3
+ import { notFound } from '@/lib/server/collection-helpers'
4
+ import { connectMcpServer, disconnectMcpServer } from '@/lib/server/mcp-client'
5
+
6
+ // Tokenizer-free estimate — same formula as @swarmclawai/mcp-gateway's
7
+ // tokens.ts so the two numbers line up when users compare side-by-side.
8
+ const CHARS_PER_TOKEN = 3.5
9
+
10
+ function estimateToolTokens(tool: {
11
+ name: string
12
+ description?: string
13
+ inputSchema?: unknown
14
+ }): number {
15
+ const json = JSON.stringify({
16
+ name: tool.name,
17
+ description: tool.description ?? '',
18
+ inputSchema: tool.inputSchema ?? {},
19
+ })
20
+ return Math.ceil(json.length / CHARS_PER_TOKEN)
21
+ }
22
+
23
+ /**
24
+ * Discovery + token-cost endpoint for an MCP server. Connects, lists tools,
25
+ * estimates per-tool schema tokens, and returns aggregate totals — including
26
+ * how many tokens would actually be bound given the server's current
27
+ * alwaysExpose policy. The MCP servers UI uses this to render the token-cost
28
+ * badge on each card and the per-tool checklist in the allow-list editor.
29
+ */
30
+ export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
31
+ const { id } = await params
32
+ const servers = loadMcpServers()
33
+ const config = servers[id]
34
+ if (!config) return notFound()
35
+
36
+ let client: Awaited<ReturnType<typeof connectMcpServer>>['client'] | null = null
37
+ let transport: Awaited<ReturnType<typeof connectMcpServer>>['transport'] | null = null
38
+ try {
39
+ const conn = await connectMcpServer(config)
40
+ client = conn.client
41
+ transport = conn.transport
42
+ const { tools } = await client.listTools()
43
+ const detailed = tools.map((t: { name: string; description?: string; inputSchema?: unknown }) => ({
44
+ name: t.name,
45
+ description: t.description ?? '',
46
+ inputSchema: t.inputSchema ?? {},
47
+ tokens: estimateToolTokens(t),
48
+ }))
49
+ const totalTokens = detailed.reduce((n: number, t: { tokens: number }) => n + t.tokens, 0)
50
+ const mode = config.alwaysExpose === undefined ? true : config.alwaysExpose
51
+ const exposedTokens =
52
+ mode === true
53
+ ? totalTokens
54
+ : mode === false
55
+ ? 0
56
+ : detailed
57
+ .filter((t: { name: string }) => (mode as string[]).includes(t.name))
58
+ .reduce((n: number, t: { tokens: number }) => n + t.tokens, 0)
59
+ return NextResponse.json({
60
+ tools: detailed,
61
+ totalTokens,
62
+ exposedTokens,
63
+ alwaysExpose: mode,
64
+ })
65
+ } catch (err: unknown) {
66
+ return NextResponse.json(
67
+ { error: err instanceof Error ? err.message : 'MCP connection failed' },
68
+ { status: 502 },
69
+ )
70
+ } finally {
71
+ if (client && transport) {
72
+ await disconnectMcpServer(client, transport)
73
+ }
74
+ }
75
+ }
@@ -61,6 +61,16 @@ test('MCP server routes exercise a live stdio server end to end', async () => {
61
61
  assert.equal(health.ok, true)
62
62
  assert.deepEqual(health.tools, ['mcp_smoke_ping', 'mcp_smoke_echo', 'mcp_smoke_cwd_check'])
63
63
 
64
+ // `reset=1` still works and succeeds — used by the explicit "Re-test" button
65
+ // to force pool eviction. Default (no query) path skips eviction so
66
+ // auto-probes don't disrupt in-flight agent MCP calls.
67
+ const resetHealthResponse = await testMcpServer(new Request(`http://local/api/mcp-servers/${serverId}/test?reset=1`, {
68
+ method: 'POST',
69
+ }), routeParams(serverId))
70
+ assert.equal(resetHealthResponse.status, 200)
71
+ const resetHealth = await resetHealthResponse.json() as Record<string, unknown>
72
+ assert.equal(resetHealth.ok, true)
73
+
64
74
  const toolsResponse = await listMcpTools(new Request(`http://local/api/mcp-servers/${serverId}/tools`), routeParams(serverId))
65
75
  assert.equal(toolsResponse.status, 200)
66
76
  const tools = await toolsResponse.json() as Array<Record<string, unknown>>
package/src/cli/index.js CHANGED
@@ -367,10 +367,19 @@ const COMMAND_GROUPS = [
367
367
  cmd('delete', 'DELETE', '/mcp-servers/:id', 'Delete MCP server'),
368
368
  cmd('test', 'POST', '/mcp-servers/:id/test', 'Test MCP server connection'),
369
369
  cmd('tools', 'GET', '/mcp-servers/:id/tools', 'List tools available on an MCP server'),
370
+ cmd('tools-info', 'GET', '/mcp-servers/:id/tools-info', 'List tools with token-cost estimates and exposure status'),
370
371
  cmd('conformance', 'POST', '/mcp-servers/:id/conformance', 'Run MCP conformance checks for a server', { expectsJsonBody: true }),
371
372
  cmd('invoke', 'POST', '/mcp-servers/:id/invoke', 'Invoke an MCP tool on a server', { expectsJsonBody: true }),
372
373
  ],
373
374
  },
375
+ {
376
+ name: 'mcp-registry',
377
+ description: 'Browse the public SwarmDock MCP Registry',
378
+ commands: [
379
+ cmd('search', 'GET', '/mcp-registry', 'Search registry servers (supports --query q=postgres,limit=20)'),
380
+ cmd('get', 'GET', '/mcp-registry/:slug', 'Get registry server detail by slug'),
381
+ ],
382
+ },
374
383
  {
375
384
  name: 'memories',
376
385
  description: 'Alias of memory command group',
@@ -585,7 +594,10 @@ const COMMAND_GROUPS = [
585
594
  responseType: 'sse',
586
595
  }),
587
596
  cmd('stop', 'POST', '/chats/:id/stop', 'Stop chat run(s)'),
588
- cmd('clear', 'POST', '/chats/:id/clear', 'Clear chat messages'),
597
+ cmd('clear', 'POST', '/chats/:id/clear', 'Clear chat messages (returns undoToken with a 30s TTL)'),
598
+ cmd('clear-undo', 'POST', '/chats/:id/clear/undo', 'Restore a cleared chat via its undoToken', { expectsJsonBody: true }),
599
+ cmd('compact', 'POST', '/chats/:id/compact', 'Summarize and compact chat history (accepts optional keepLastN)', { expectsJsonBody: true }),
600
+ cmd('context-status', 'GET', '/chats/:id/context-status', 'Report token usage and context-window status for a chat'),
589
601
  cmd('browser-status', 'GET', '/chats/:id/browser', 'Check browser status'),
590
602
  cmd('browser-close', 'DELETE', '/chats/:id/browser', 'Close browser'),
591
603
  cmd('mailbox', 'GET', '/chats/:id/mailbox', 'List chat mailbox envelopes'),
package/src/cli/spec.js CHANGED
@@ -435,7 +435,10 @@ const COMMAND_GROUPS = {
435
435
  'edit-resend': { description: 'Edit and resend from a specific message index', method: 'POST', path: '/chats/:id/edit-resend', params: ['id'] },
436
436
  chat: { description: 'Send chat message (SSE stream)', method: 'POST', path: '/chats/:id/chat', params: ['id'], stream: true, waitable: true },
437
437
  stop: { description: 'Cancel active/running chat work', method: 'POST', path: '/chats/:id/stop', params: ['id'] },
438
- clear: { description: 'Clear chat history', method: 'POST', path: '/chats/:id/clear', params: ['id'] },
438
+ clear: { description: 'Clear chat history (returns undoToken with 30s TTL)', method: 'POST', path: '/chats/:id/clear', params: ['id'] },
439
+ 'clear-undo': { description: 'Restore a cleared chat via its undoToken', method: 'POST', path: '/chats/:id/clear/undo', params: ['id'] },
440
+ compact: { description: 'Summarize and compact chat history', method: 'POST', path: '/chats/:id/compact', params: ['id'] },
441
+ 'context-status': { description: 'Report token usage and context-window status', method: 'GET', path: '/chats/:id/context-status', params: ['id'] },
439
442
  mailbox: { description: 'List mailbox envelopes for a chat', method: 'GET', path: '/chats/:id/mailbox', params: ['id'] },
440
443
  'mailbox-action': { description: 'Send/ack/clear mailbox envelopes', method: 'POST', path: '/chats/:id/mailbox', params: ['id'] },
441
444
  queue: { description: 'List queued follow-up turns for a chat', method: 'GET', path: '/chats/:id/queue', params: ['id'] },
@@ -6,7 +6,9 @@ import { useAppStore } from '@/stores/use-app-store'
6
6
  import { selectActiveSessionId } from '@/stores/slices/session-slice'
7
7
  import { useWs } from '@/hooks/use-ws'
8
8
  import { useChatStore } from '@/stores/use-chat-store'
9
- import { fetchMessages, fetchMessagesPaginated, clearMessages, deleteChat, devServer, checkBrowser, stopBrowser } from '@/lib/chat/chats'
9
+ import { fetchMessages, fetchMessagesPaginated, clearMessages, undoClearMessages, deleteChat, devServer, checkBrowser, stopBrowser } from '@/lib/chat/chats'
10
+ import { toast } from 'sonner'
11
+ import { errorMessage } from '@/lib/shared-utils'
10
12
  import { uploadImage } from '@/lib/upload'
11
13
  import { deleteAgent } from '@/lib/agents'
12
14
  import { useMediaQuery } from '@/hooks/use-media-query'
@@ -432,11 +434,59 @@ export function ChatArea() {
432
434
  const handleClear = useCallback(async () => {
433
435
  setConfirmClear(false)
434
436
  if (!sessionId) return
435
- await clearMessages(sessionId)
436
- setMessages([], { startIndex: 0, totalMessages: 0 })
437
- await refreshSession(sessionId)
437
+ const targetSessionId = sessionId
438
+ let result
439
+ try {
440
+ result = await clearMessages(targetSessionId)
441
+ } catch (err) {
442
+ toast.error(`Clear failed: ${errorMessage(err)}`)
443
+ return
444
+ }
445
+ if (selectActiveSessionId(useAppStore.getState()) === targetSessionId) {
446
+ setMessages([], { startIndex: 0, totalMessages: 0 })
447
+ }
448
+ await refreshSession(targetSessionId)
449
+ const { undoToken, cleared } = result
450
+ if (!undoToken) return
451
+ const clearedLabel = cleared === 1 ? '1 message cleared' : `${cleared.toLocaleString()} messages cleared`
452
+ toast(clearedLabel, {
453
+ duration: 10_000,
454
+ action: {
455
+ label: 'Undo',
456
+ onClick: async () => {
457
+ try {
458
+ await undoClearMessages(targetSessionId, undoToken)
459
+ const restored = await fetchMessages(targetSessionId)
460
+ if (selectActiveSessionId(useAppStore.getState()) === targetSessionId) {
461
+ setMessages(restored, { startIndex: 0, totalMessages: restored.length })
462
+ }
463
+ await refreshSession(targetSessionId)
464
+ toast.success('Chat restored.')
465
+ } catch (err) {
466
+ toast.error(`Undo failed: ${errorMessage(err)}`)
467
+ }
468
+ },
469
+ },
470
+ })
438
471
  }, [refreshSession, sessionId, setMessages])
439
472
 
473
+ const handleCompactComplete = useCallback(async () => {
474
+ if (!sessionId) return
475
+ const targetSessionId = sessionId
476
+ try {
477
+ const refreshed = await fetchMessages(targetSessionId)
478
+ if (selectActiveSessionId(useAppStore.getState()) === targetSessionId) {
479
+ setMessages(refreshed, { startIndex: 0, totalMessages: refreshed.length })
480
+ }
481
+ } catch {
482
+ // silent — next poll will catch up
483
+ }
484
+ }, [sessionId, setMessages])
485
+
486
+ const handleClearRequest = useCallback(() => {
487
+ setConfirmClear(true)
488
+ }, [])
489
+
440
490
  const handleDelete = useCallback(async () => {
441
491
  setConfirmDelete(false)
442
492
  if (!sessionId) return
@@ -515,6 +565,9 @@ export function ChatArea() {
515
565
  connectorFilter={connectorFilter}
516
566
  onConnectorFilterChange={setConnectorFilter}
517
567
  hasMultipleSources={hasMultipleSources}
568
+ messageCount={messages.length}
569
+ onCompactComplete={handleCompactComplete}
570
+ onClearRequest={handleClearRequest}
518
571
  />
519
572
  )}
520
573
  {!isDesktop && (
@@ -533,6 +586,9 @@ export function ChatArea() {
533
586
  connectorFilter={connectorFilter}
534
587
  onConnectorFilterChange={setConnectorFilter}
535
588
  hasMultipleSources={hasMultipleSources}
589
+ messageCount={messages.length}
590
+ onCompactComplete={handleCompactComplete}
591
+ onClearRequest={handleClearRequest}
536
592
  />
537
593
  )}
538
594
  <DevServerBar status={devServerStatus} onStop={handleStopDevServer} />
@@ -660,8 +716,8 @@ export function ChatArea() {
660
716
 
661
717
  <ConfirmDialog
662
718
  open={confirmClear}
663
- title="Clear History"
664
- message="This will delete all messages in this chat. This cannot be undone."
719
+ title="Clear chat"
720
+ message="Clear every message in this chat. Long-term memory, skills, and facts are preserved. You'll have 10 seconds to undo."
665
721
  confirmLabel="Clear"
666
722
  danger
667
723
  onConfirm={handleClear}
@@ -17,6 +17,7 @@ import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip
17
17
  import { copyTextToClipboard } from '@/lib/clipboard'
18
18
  import { useNavigate } from '@/lib/app/navigation'
19
19
  import { getEnabledToolIds } from '@/lib/capability-selection'
20
+ import { ContextMeterBadge } from './context-meter-badge'
20
21
 
21
22
  function Tip({ label, children, side = 'bottom' }: { label: string; children: ReactNode; side?: 'top' | 'bottom' | 'left' | 'right' }) {
22
23
  return (
@@ -80,9 +81,12 @@ interface Props {
80
81
  connectorFilter?: string | null
81
82
  onConnectorFilterChange?: (filter: string | null) => void
82
83
  hasMultipleSources?: boolean
84
+ messageCount?: number
85
+ onCompactComplete?: () => void
86
+ onClearRequest?: () => void
83
87
  }
84
88
 
85
- export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, mobile, browserActive, onStopBrowser, onVoiceToggle, voiceActive, voiceSupported, connectorSources, connectorFilter, onConnectorFilterChange, hasMultipleSources }: Props) {
89
+ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, mobile, browserActive, onStopBrowser, onVoiceToggle, voiceActive, voiceSupported, connectorSources, connectorFilter, onConnectorFilterChange, hasMultipleSources, messageCount = 0, onCompactComplete, onClearRequest }: Props) {
86
90
  const now = useNow()
87
91
  const agentStatus = useChatStore((s) => s.agentStatus)
88
92
  const agents = useAppStore((s) => s.agents)
@@ -419,6 +423,14 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
419
423
  Responding
420
424
  </HeaderChip>
421
425
  )}
426
+ {messageCount > 0 && onCompactComplete && onClearRequest && (
427
+ <ContextMeterBadge
428
+ sessionId={session.id}
429
+ messageCount={messageCount}
430
+ onCompactComplete={onCompactComplete}
431
+ onClearRequest={onClearRequest}
432
+ />
433
+ )}
422
434
  </div>
423
435
  {liveStatus?.status && (
424
436
  <div className="mt-1.5 flex min-w-0 flex-wrap items-center gap-1.5">
@@ -0,0 +1,227 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useEffect, useRef, useState } from 'react'
4
+ import { toast } from 'sonner'
5
+ import {
6
+ compactChat,
7
+ fetchContextStatus,
8
+ type ContextStatusResponse,
9
+ } from '@/lib/chat/chats'
10
+ import { useWs } from '@/hooks/use-ws'
11
+ import { errorMessage } from '@/lib/shared-utils'
12
+
13
+ interface Props {
14
+ sessionId: string
15
+ messageCount: number
16
+ onCompactComplete: () => void
17
+ onClearRequest: () => void
18
+ }
19
+
20
+ function formatTokens(n: number): string {
21
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
22
+ if (n >= 1_000) return `${(n / 1_000).toFixed(n < 10_000 ? 1 : 0)}k`
23
+ return `${n}`
24
+ }
25
+
26
+ function resolveColor(strategy: ContextStatusResponse['strategy']): {
27
+ dot: string
28
+ text: string
29
+ border: string
30
+ bg: string
31
+ } {
32
+ if (strategy === 'critical') {
33
+ return {
34
+ dot: 'bg-red-400',
35
+ text: 'text-red-300',
36
+ border: 'border-red-500/25',
37
+ bg: 'bg-red-500/10',
38
+ }
39
+ }
40
+ if (strategy === 'warning') {
41
+ return {
42
+ dot: 'bg-amber-400',
43
+ text: 'text-amber-300',
44
+ border: 'border-amber-500/25',
45
+ bg: 'bg-amber-500/10',
46
+ }
47
+ }
48
+ return {
49
+ dot: 'bg-emerald-400/80',
50
+ text: 'text-text-3/70',
51
+ border: 'border-white/[0.06]',
52
+ bg: 'bg-white/[0.03]',
53
+ }
54
+ }
55
+
56
+ export function ContextMeterBadge({ sessionId, messageCount, onCompactComplete, onClearRequest }: Props) {
57
+ const [status, setStatus] = useState<ContextStatusResponse | null>(null)
58
+ const [open, setOpen] = useState(false)
59
+ const [compacting, setCompacting] = useState(false)
60
+ const anchorRef = useRef<HTMLDivElement>(null)
61
+ const panelRef = useRef<HTMLDivElement>(null)
62
+
63
+ const loadStatus = useCallback(async () => {
64
+ try {
65
+ const next = await fetchContextStatus(sessionId)
66
+ setStatus(next)
67
+ } catch {
68
+ // silent — badge just won't render
69
+ }
70
+ }, [sessionId])
71
+
72
+ useEffect(() => {
73
+ void loadStatus()
74
+ }, [loadStatus, messageCount])
75
+
76
+ useWs(`messages:${sessionId}`, loadStatus)
77
+
78
+ useEffect(() => {
79
+ if (!open) return
80
+ const handler = (event: MouseEvent) => {
81
+ const target = event.target as Node
82
+ if (anchorRef.current?.contains(target)) return
83
+ if (panelRef.current?.contains(target)) return
84
+ setOpen(false)
85
+ }
86
+ document.addEventListener('mousedown', handler)
87
+ return () => document.removeEventListener('mousedown', handler)
88
+ }, [open])
89
+
90
+ useEffect(() => {
91
+ if (!open) return
92
+ const handler = (event: KeyboardEvent) => {
93
+ if (event.key === 'Escape') setOpen(false)
94
+ }
95
+ document.addEventListener('keydown', handler)
96
+ return () => document.removeEventListener('keydown', handler)
97
+ }, [open])
98
+
99
+ const handleCompact = useCallback(async () => {
100
+ if (compacting) return
101
+ setCompacting(true)
102
+ try {
103
+ const result = await compactChat(sessionId)
104
+ if (result.status === 'no_action') {
105
+ toast('Nothing to compact yet.')
106
+ } else {
107
+ toast.success(
108
+ `Compacted ${result.prunedCount ?? 0} message${result.prunedCount === 1 ? '' : 's'}.`,
109
+ )
110
+ }
111
+ onCompactComplete()
112
+ await loadStatus()
113
+ setOpen(false)
114
+ } catch (err) {
115
+ toast.error(`Compact failed: ${errorMessage(err)}`)
116
+ } finally {
117
+ setCompacting(false)
118
+ }
119
+ }, [compacting, loadStatus, onCompactComplete, sessionId])
120
+
121
+ const handleClearClick = useCallback(() => {
122
+ setOpen(false)
123
+ onClearRequest()
124
+ }, [onClearRequest])
125
+
126
+ if (!status) return null
127
+ const colors = resolveColor(status.strategy)
128
+ const percent = Math.min(100, Math.max(0, status.percentUsed))
129
+
130
+ return (
131
+ <div className="relative" ref={anchorRef}>
132
+ <button
133
+ type="button"
134
+ onClick={() => setOpen((prev) => !prev)}
135
+ className={`inline-flex items-center gap-1.5 rounded-[9px] border px-2.5 py-1 text-[10px] font-600 transition-colors shrink-0 cursor-pointer ${colors.bg} ${colors.border} ${colors.text} hover:border-white/[0.15] hover:text-text-2`}
136
+ title={`${status.effectiveTokens.toLocaleString()} of ${status.contextWindow.toLocaleString()} tokens used`}
137
+ aria-expanded={open}
138
+ aria-label={`Context usage ${percent}%. Click for details.`}
139
+ >
140
+ <span className={`w-1.5 h-1.5 rounded-full ${colors.dot}`} />
141
+ <span>{percent}%</span>
142
+ <span className="text-text-3/45 font-500">
143
+ {formatTokens(status.effectiveTokens)}
144
+ </span>
145
+ </button>
146
+ {open && (
147
+ <div
148
+ ref={panelRef}
149
+ className="absolute right-0 top-[calc(100%+6px)] z-50 w-[280px] rounded-[14px] border border-white/[0.08] bg-raised/95 p-3 shadow-[0_16px_64px_rgba(0,0,0,0.6)] backdrop-blur-xl"
150
+ style={{ animation: 'fade-in 0.15s cubic-bezier(0.16, 1, 0.3, 1)' }}
151
+ >
152
+ <div className="flex items-baseline justify-between">
153
+ <span className="text-[11px] font-600 uppercase tracking-wider text-text-3/60">Context window</span>
154
+ <span className={`text-[11px] font-600 ${colors.text}`}>{percent}%</span>
155
+ </div>
156
+ <div className="mt-2 h-1.5 w-full rounded-full bg-white/[0.06] overflow-hidden">
157
+ <div
158
+ className={`h-full rounded-full transition-all ${
159
+ status.strategy === 'critical' ? 'bg-red-400'
160
+ : status.strategy === 'warning' ? 'bg-amber-400'
161
+ : 'bg-emerald-400/80'
162
+ }`}
163
+ style={{ width: `${percent}%` }}
164
+ />
165
+ </div>
166
+ <dl className="mt-3 space-y-1 text-[11px]">
167
+ <div className="flex justify-between">
168
+ <dt className="text-text-3/60">Used</dt>
169
+ <dd className="text-text-2 font-mono">
170
+ {status.effectiveTokens.toLocaleString()} / {status.contextWindow.toLocaleString()}
171
+ </dd>
172
+ </div>
173
+ <div className="flex justify-between">
174
+ <dt className="text-text-3/60">Remaining</dt>
175
+ <dd className="text-text-2 font-mono">{status.remainingTokens.toLocaleString()}</dd>
176
+ </div>
177
+ <div className="flex justify-between">
178
+ <dt className="text-text-3/60">Messages</dt>
179
+ <dd className="text-text-2 font-mono">{status.messageCount}</dd>
180
+ </div>
181
+ </dl>
182
+ <div className="mt-3 flex gap-2">
183
+ <button
184
+ type="button"
185
+ onClick={handleCompact}
186
+ disabled={compacting || status.messageCount < 3}
187
+ className="flex-1 inline-flex items-center justify-center gap-1.5 rounded-[10px] border border-accent-bright/25 bg-accent-soft/40 px-2.5 py-1.5 text-[11px] font-600 text-accent-bright transition-colors hover:border-accent-bright/40 hover:bg-accent-soft/60 disabled:cursor-not-allowed disabled:opacity-50"
188
+ >
189
+ {compacting ? (
190
+ <>
191
+ <span className="w-3 h-3 rounded-full border-2 border-accent-bright/30 border-t-accent-bright animate-spin" />
192
+ Compacting
193
+ </>
194
+ ) : (
195
+ <>
196
+ <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
197
+ <path d="M4 14h6v6" />
198
+ <path d="M20 10h-6V4" />
199
+ <path d="M14 10 21 3" />
200
+ <path d="M3 21l7-7" />
201
+ </svg>
202
+ Compact
203
+ </>
204
+ )}
205
+ </button>
206
+ <button
207
+ type="button"
208
+ onClick={handleClearClick}
209
+ className="flex-1 inline-flex items-center justify-center gap-1.5 rounded-[10px] border border-white/[0.08] bg-white/[0.03] px-2.5 py-1.5 text-[11px] font-600 text-text-2 transition-colors hover:border-red-500/25 hover:bg-red-500/10 hover:text-red-300"
210
+ >
211
+ <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
212
+ <path d="M3 6h18" />
213
+ <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" />
214
+ <path d="M10 11v6" />
215
+ <path d="M14 11v6" />
216
+ </svg>
217
+ Clear
218
+ </button>
219
+ </div>
220
+ <p className="mt-2.5 text-[10px] leading-relaxed text-text-3/55">
221
+ Long-term memory, skills, and facts are preserved. Clear only affects this chat transcript.
222
+ </p>
223
+ </div>
224
+ )}
225
+ </div>
226
+ )
227
+ }
@@ -16,6 +16,7 @@ const transportColors: Record<string, string> = {
16
16
 
17
17
  type McpStatus = { ok: boolean; tools?: string[]; error?: string; loading: boolean }
18
18
  type McpToolMeta = { name: string; description?: string; inputSchema?: Record<string, unknown> }
19
+ type McpToolsInfo = { totalTokens: number; exposedTokens: number; toolCount: number; loading?: boolean; error?: string }
19
20
  type McpInvokeResult = { ok: boolean; text?: string; error?: string; isError?: boolean; result?: unknown }
20
21
  type McpConformanceIssue = { level: 'error' | 'warning'; code: string; message: string; toolName?: string }
21
22
  type McpConformanceResult = {
@@ -56,6 +57,7 @@ export function McpServerList({ inSidebar }: { inSidebar?: boolean }) {
56
57
  const setMcpServerSheetOpen = useAppStore((s) => s.setMcpServerSheetOpen)
57
58
  const setEditingMcpServerId = useAppStore((s) => s.setEditingMcpServerId)
58
59
  const [statuses, setStatuses] = useState<Record<string, McpStatus>>({})
60
+ const [toolsInfo, setToolsInfo] = useState<Record<string, McpToolsInfo>>({})
59
61
  const [inspectorServerId, setInspectorServerId] = useState<string | null>(null)
60
62
  const [toolsByServer, setToolsByServer] = useState<Record<string, McpToolMeta[]>>({})
61
63
  const [inspectorLoading, setInspectorLoading] = useState(false)
@@ -111,6 +113,41 @@ export function McpServerList({ inSidebar }: { inSidebar?: boolean }) {
111
113
  timersRef.current.push(timer)
112
114
  })
113
115
 
116
+ // Tools-info — fetch in parallel with tests, staggered to avoid hammering
117
+ // every server at once. Piggybacks on the same connect that /test does,
118
+ // just produces richer data.
119
+ serverList.forEach((server, i) => {
120
+ setToolsInfo((prev) => ({ ...prev, [server.id]: { totalTokens: 0, exposedTokens: 0, toolCount: 0, loading: true } }))
121
+ const timer = setTimeout(async () => {
122
+ try {
123
+ const res = await api<{ tools: Array<{ tokens: number }>; totalTokens: number; exposedTokens: number }>('GET', `/mcp-servers/${server.id}/tools-info`)
124
+ if (cancelled || !mountedRef.current) return
125
+ setToolsInfo((prev) => ({
126
+ ...prev,
127
+ [server.id]: {
128
+ totalTokens: res.totalTokens,
129
+ exposedTokens: res.exposedTokens,
130
+ toolCount: res.tools?.length ?? 0,
131
+ loading: false,
132
+ },
133
+ }))
134
+ } catch (err: unknown) {
135
+ if (cancelled || !mountedRef.current) return
136
+ setToolsInfo((prev) => ({
137
+ ...prev,
138
+ [server.id]: {
139
+ totalTokens: 0,
140
+ exposedTokens: 0,
141
+ toolCount: 0,
142
+ loading: false,
143
+ error: err instanceof Error ? err.message : 'fetch failed',
144
+ },
145
+ }))
146
+ }
147
+ }, i * 200 + 100)
148
+ timersRef.current.push(timer)
149
+ })
150
+
114
151
  return () => {
115
152
  cancelled = true
116
153
  timersRef.current.forEach(clearTimeout)
@@ -151,7 +188,7 @@ export function McpServerList({ inSidebar }: { inSidebar?: boolean }) {
151
188
  e.stopPropagation()
152
189
  setStatuses((prev) => ({ ...prev, [id]: { ok: false, loading: true } }))
153
190
  try {
154
- const res = await api<{ ok: boolean; tools?: string[]; error?: string }>('POST', `/mcp-servers/${id}/test`)
191
+ const res = await api<{ ok: boolean; tools?: string[]; error?: string }>('POST', `/mcp-servers/${id}/test?reset=1`)
155
192
  if (!mountedRef.current) return
156
193
  setStatuses((prev) => ({ ...prev, [id]: { ok: res.ok, tools: res.tools, error: res.error, loading: false } }))
157
194
  if (res.ok) toast.success('Connection test passed')
@@ -472,6 +509,25 @@ export function McpServerList({ inSidebar }: { inSidebar?: boolean }) {
472
509
  <p className="text-[12px] text-text-3/60 font-mono truncate">
473
510
  {server.transport === 'stdio' ? server.command : server.url}
474
511
  </p>
512
+ {(() => {
513
+ const info = toolsInfo[server.id]
514
+ if (!info || info.loading || info.error) return null
515
+ if (info.totalTokens === 0) return null
516
+ const savings = info.totalTokens > 0
517
+ ? Math.round((1 - info.exposedTokens / info.totalTokens) * 100)
518
+ : 0
519
+ return (
520
+ <p className="mt-1 text-[11px] font-mono text-text-3/75">
521
+ <span className={info.exposedTokens < info.totalTokens ? 'text-emerald-400/80' : 'text-text-3'}>
522
+ {info.exposedTokens.toLocaleString()}
523
+ </span>
524
+ <span className="text-text-3/50"> / {info.totalTokens.toLocaleString()} tokens exposed</span>
525
+ {savings > 0 && (
526
+ <span className="ml-2 text-emerald-400/80">({savings}% saved)</span>
527
+ )}
528
+ </p>
529
+ )
530
+ })()}
475
531
  {conformanceByServer[server.id] && (
476
532
  <p className={`mt-1 text-[11px] ${conformanceByServer[server.id].ok ? 'text-emerald-300/80' : 'text-amber-300/80'}`}>
477
533
  {conformanceByServer[server.id].ok