@swarmclawai/swarmclaw 1.5.63 → 1.5.64

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 (32) hide show
  1. package/README.md +17 -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-servers/[id]/route.ts +5 -0
  11. package/src/app/api/mcp-servers/[id]/test/route.ts +5 -0
  12. package/src/app/api/mcp-servers/[id]/tools-info/route.ts +75 -0
  13. package/src/cli/index.js +5 -1
  14. package/src/cli/spec.js +4 -1
  15. package/src/components/chat/chat-area.tsx +62 -6
  16. package/src/components/chat/chat-header.tsx +13 -1
  17. package/src/components/chat/context-meter-badge.tsx +227 -0
  18. package/src/components/mcp-servers/mcp-server-list.tsx +56 -0
  19. package/src/components/mcp-servers/mcp-server-sheet.tsx +202 -1
  20. package/src/components/mcp-servers/registry-browser.tsx +224 -0
  21. package/src/lib/chat/chats.ts +37 -1
  22. package/src/lib/server/chats/chat-session-service.ts +75 -0
  23. package/src/lib/server/chats/clear-undo-snapshots.test.ts +107 -0
  24. package/src/lib/server/chats/clear-undo-snapshots.ts +92 -0
  25. package/src/lib/server/mcp-connection-pool.test.ts +98 -0
  26. package/src/lib/server/mcp-connection-pool.ts +134 -0
  27. package/src/lib/server/mcp-gateway-runtime.test.ts +177 -0
  28. package/src/lib/server/mcp-gateway-runtime.ts +138 -0
  29. package/src/lib/server/session-tools/index.ts +83 -15
  30. package/src/lib/server/storage-normalization.ts +11 -0
  31. package/src/types/agent.ts +1 -0
  32. package/src/types/misc.ts +7 -0
@@ -0,0 +1,138 @@
1
+ import type { McpServerConfig } from '@/types'
2
+ import { hmrSingleton } from '@/lib/shared-utils'
3
+
4
+ /**
5
+ * Gateway-style runtime for SwarmClaw's MCP integration. Mirrors the behavior
6
+ * of `@swarmclawai/mcp-gateway`'s router (alwaysExpose filter + mcp_tool_search
7
+ * meta-tool) but stays inside SwarmClaw — no external dep on mcp-core (yet).
8
+ *
9
+ * Once `@swarmclawai/mcp-core` is published to npm, the plan is:
10
+ * 1. Add `@swarmclawai/mcp-core` to SwarmClaw's dependencies.
11
+ * 2. Replace `SessionToolPromoter`, `searchDiscoveredTools`, and
12
+ * `shouldExposeMcpTool` with imports from mcp-core. The field names on
13
+ * `DownstreamTool` differ from `DiscoveredTool` (`prefixedName` vs
14
+ * `langChainName`) — add a thin adapter during migration.
15
+ * 3. Keep the hmrSingleton `state` here; mcp-core provides primitives, but
16
+ * SwarmClaw still owns process-wide session-scoped storage.
17
+ *
18
+ * Contains two pieces of shared state, both HMR-safe:
19
+ * - `SessionToolPromoter` instances keyed by sessionId. The agent calls
20
+ * `mcp_tool_search` to promote a lazy tool by name; the next turn's tool
21
+ * bind picks up the promoted name via `isPromoted`.
22
+ * - A discovery cache keyed by MCP server id, so even lazy servers have
23
+ * their tool schemas known in-process for `mcp_tool_search` to match
24
+ * against without a cold connect.
25
+ */
26
+
27
+ export interface DiscoveredTool {
28
+ name: string // bare tool name as the downstream reported it
29
+ langChainName: string // the `mcp_<server>_<tool>` name SwarmClaw binds it under
30
+ description?: string
31
+ inputSchema?: unknown
32
+ serverId: string
33
+ serverName: string
34
+ }
35
+
36
+ export class SessionToolPromoter {
37
+ private readonly exposed = new Set<string>()
38
+ allow(langChainName: string): boolean { return this.exposed.has(langChainName) }
39
+ promote(langChainName: string): void { this.exposed.add(langChainName) }
40
+ promoteMany(names: readonly string[]): void { for (const n of names) this.exposed.add(n) }
41
+ promoted(): string[] { return Array.from(this.exposed) }
42
+ clear(): void { this.exposed.clear() }
43
+ }
44
+
45
+ interface RuntimeState {
46
+ promoters: Map<string, SessionToolPromoter>
47
+ // serverId -> discovered tools (updated opportunistically when we connect)
48
+ discovered: Map<string, DiscoveredTool[]>
49
+ }
50
+
51
+ const state = hmrSingleton<RuntimeState>('mcpGatewayRuntime', () => ({
52
+ promoters: new Map<string, SessionToolPromoter>(),
53
+ discovered: new Map<string, DiscoveredTool[]>(),
54
+ }))
55
+
56
+ export function getPromoter(sessionId: string): SessionToolPromoter {
57
+ let p = state.promoters.get(sessionId)
58
+ if (!p) {
59
+ p = new SessionToolPromoter()
60
+ state.promoters.set(sessionId, p)
61
+ }
62
+ return p
63
+ }
64
+
65
+ export function clearPromoter(sessionId: string): void {
66
+ state.promoters.delete(sessionId)
67
+ }
68
+
69
+ export function recordDiscoveredTools(serverId: string, tools: DiscoveredTool[]): void {
70
+ state.discovered.set(serverId, tools)
71
+ }
72
+
73
+ export function allDiscoveredTools(): DiscoveredTool[] {
74
+ const out: DiscoveredTool[] = []
75
+ for (const arr of state.discovered.values()) {
76
+ for (const t of arr) out.push(t)
77
+ }
78
+ return out
79
+ }
80
+
81
+ /**
82
+ * Decide whether a given tool, from a given server, should be bound on this
83
+ * turn. Order of precedence:
84
+ * 1. Per-agent eager allowlist (`mcpEagerTools`) — agent-scoped override
85
+ * 2. Server-level `alwaysExpose`
86
+ * - true (default) → bind
87
+ * - false → skip unless promoted
88
+ * - string[] → bind only if tool name is on the list
89
+ * 3. Session promoter — if the agent has called `mcp_tool_search` this
90
+ * session and promoted this tool, bind it regardless of (2).
91
+ */
92
+ export function shouldExposeMcpTool(opts: {
93
+ server: McpServerConfig
94
+ toolName: string
95
+ langChainName: string
96
+ agentEagerTools?: readonly string[] | null
97
+ promoter?: SessionToolPromoter | null
98
+ }): boolean {
99
+ const { server, toolName, langChainName, agentEagerTools, promoter } = opts
100
+ if (agentEagerTools && agentEagerTools.includes(toolName)) return true
101
+ if (agentEagerTools && agentEagerTools.includes(langChainName)) return true
102
+ if (promoter?.allow(langChainName)) return true
103
+ const mode = server.alwaysExpose
104
+ if (mode === undefined || mode === true) return true
105
+ if (mode === false) return false
106
+ if (Array.isArray(mode)) return mode.includes(toolName)
107
+ return true
108
+ }
109
+
110
+ export interface ToolSearchMatch {
111
+ name: string // langChainName
112
+ server: string
113
+ description?: string
114
+ score: number
115
+ }
116
+
117
+ export function searchDiscoveredTools(query: string, limit = 8): ToolSearchMatch[] {
118
+ const q = query.trim().toLowerCase()
119
+ if (!q) return []
120
+ const terms = q.split(/\s+/).filter((t) => t.length >= 2)
121
+ const clamped = Math.max(1, Math.min(limit, 50))
122
+ const scored = allDiscoveredTools().map((t): ToolSearchMatch => {
123
+ const haystack = `${t.langChainName} ${t.description ?? ''}`.toLowerCase()
124
+ let score = 0
125
+ if (haystack.includes(q)) score += 0.6
126
+ let termHits = 0
127
+ for (const term of terms) if (haystack.includes(term)) termHits += 1
128
+ if (terms.length) score += 0.4 * (termHits / terms.length)
129
+ return {
130
+ name: t.langChainName,
131
+ server: t.serverName,
132
+ description: t.description,
133
+ score: Math.min(1, score),
134
+ }
135
+ })
136
+ scored.sort((a, b) => b.score - a.score)
137
+ return scored.filter((m) => m.score > 0).slice(0, clamped)
138
+ }
@@ -54,7 +54,15 @@ import { enforceFileAccessPolicy } from './file-access-policy'
54
54
 
55
55
  import { getExtensionManager } from '../extensions'
56
56
  import { runCapabilityBeforeToolCall, runCapabilityHook } from '../native-capabilities'
57
- import { jsonSchemaToZod } from '../mcp-client'
57
+ import { jsonSchemaToZod, sanitizeName } from '../mcp-client'
58
+ import {
59
+ getPromoter,
60
+ recordDiscoveredTools,
61
+ searchDiscoveredTools,
62
+ shouldExposeMcpTool,
63
+ type DiscoveredTool,
64
+ } from '../mcp-gateway-runtime'
65
+ import { getOrConnectMcpClient } from '../mcp-connection-pool'
58
66
  import {
59
67
  getEnabledCapabilitySelection,
60
68
  isExternalExtensionId,
@@ -81,6 +89,11 @@ const DELEGATION_TOOL_NAMES = new Set([
81
89
  'delegate_to_qwen_code_cli',
82
90
  ])
83
91
 
92
+ function inferBareName(langChainName: string, serverName: string): string {
93
+ const prefix = `mcp_${sanitizeName(serverName)}_`
94
+ return langChainName.startsWith(prefix) ? langChainName.slice(prefix.length) : langChainName
95
+ }
96
+
84
97
  export async function buildSessionTools(cwd: string, enabledExtensions: string[], ctx?: ToolContext): Promise<SessionToolsResult> {
85
98
  const tools: StructuredToolInterface[] = []
86
99
  const cleanupFns: (() => Promise<void>)[] = []
@@ -305,33 +318,88 @@ export async function buildSessionTools(cwd: string, enabledExtensions: string[]
305
318
 
306
319
  // 3. MCP server tools
307
320
  const disabledMcpToolNames = new Set<string>(ctx?.mcpDisabledTools ?? [])
321
+ const agentEagerTools = Array.isArray(agentRecord?.mcpEagerTools) ? agentRecord.mcpEagerTools : null
322
+ const sessionPromoter = ctx?.sessionId ? getPromoter(ctx.sessionId) : null
323
+ let exposedAnyLazyCandidate = false
308
324
  if (ctx?.mcpServerIds?.length) {
309
- const mcpConnections: Array<{ client: any; transport: any }> = []
310
325
  const allMcpServers = loadMcpServers()
311
326
  for (const serverId of ctx.mcpServerIds) {
312
327
  const config = allMcpServers[serverId]
313
328
  if (!config) continue
314
329
  try {
315
- const { connectMcpServer, mcpToolsToLangChain } = await import('../mcp-client')
316
- const conn = await connectMcpServer(config)
317
- mcpConnections.push(conn)
330
+ const { mcpToolsToLangChain } = await import('../mcp-client')
331
+ const conn = await getOrConnectMcpClient(config)
318
332
  const mcpLcTools = await mcpToolsToLangChain(conn.client, config.name)
333
+ // Discovery cache — so mcp_tool_search can match even on lazy servers
334
+ // whose tools we don't bind. Populated each turn we connect.
335
+ const discovered: DiscoveredTool[] = mcpLcTools.map((t) => ({
336
+ name: inferBareName(t.name, config.name),
337
+ langChainName: t.name,
338
+ description: typeof t.description === 'string' ? t.description : undefined,
339
+ serverId,
340
+ serverName: config.name,
341
+ }))
342
+ recordDiscoveredTools(serverId, discovered)
319
343
  for (const t of mcpLcTools) {
320
- if (!disabledMcpToolNames.has(t.name)) {
321
- toolToExtensionMap[t.name] = `mcp:${serverId}`
322
- tools.push(t)
323
- }
344
+ if (disabledMcpToolNames.has(t.name)) continue
345
+ const bareName = inferBareName(t.name, config.name)
346
+ const effectiveMode = config.alwaysExpose === undefined ? true : config.alwaysExpose
347
+ if (effectiveMode !== true) exposedAnyLazyCandidate = true
348
+ const shouldBind = shouldExposeMcpTool({
349
+ server: config,
350
+ toolName: bareName,
351
+ langChainName: t.name,
352
+ agentEagerTools,
353
+ promoter: sessionPromoter,
354
+ })
355
+ if (!shouldBind) continue
356
+ toolToExtensionMap[t.name] = `mcp:${serverId}`
357
+ tools.push(t)
324
358
  }
325
359
  } catch (err: unknown) {
326
360
  log.warn('session-tools', `Failed to connect MCP server "${config.name}"`, { serverId, error: errorMessage(err) })
327
361
  }
328
362
  }
329
- cleanupFns.push(async () => {
330
- const { disconnectMcpServer } = await import('../mcp-client')
331
- for (const conn of mcpConnections) {
332
- await disconnectMcpServer(conn.client, conn.transport)
333
- }
334
- })
363
+ // Connection lifetimes are owned by the pool (hmrSingleton) no per-turn
364
+ // cleanup here. Evictions happen on server edit/delete via the mcp-servers
365
+ // API routes or via the /test endpoint.
366
+ }
367
+
368
+ // 3a. mcp_tool_search meta-tool — bound when any configured MCP server has
369
+ // a non-eager exposure mode so the agent has a path to discover lazy tools.
370
+ if (exposedAnyLazyCandidate && sessionPromoter) {
371
+ const promoter = sessionPromoter
372
+ toolToExtensionMap['mcp_tool_search'] = '_mcp_gateway'
373
+ tools.push(
374
+ tool(
375
+ async (args) => {
376
+ const normalized = normalizeToolInputArgs((args ?? {}) as Record<string, unknown>)
377
+ const query = typeof normalized.query === 'string' ? normalized.query : ''
378
+ const limit = typeof normalized.limit === 'number' ? normalized.limit : undefined
379
+ const matches = searchDiscoveredTools(query, limit)
380
+ for (const m of matches) promoter.promote(m.name)
381
+ return JSON.stringify({
382
+ query,
383
+ matches,
384
+ note: matches.length
385
+ ? 'Promoted tools will appear in the tool list on subsequent turns; call them by the listed name.'
386
+ : 'No matches — tighten your query or check enabled MCP servers.',
387
+ })
388
+ },
389
+ {
390
+ name: 'mcp_tool_search',
391
+ description: [
392
+ 'Search for tools provided by configured MCP servers that are not currently bound.',
393
+ 'Use this when you suspect a tool exists but do not see it in your available tools.',
394
+ 'Returns matching tool names and descriptions, and promotes the matches so they show up in subsequent turns.',
395
+ ].join(' '),
396
+ schema: z.object({
397
+ query: z.string().min(1).describe('Keywords to search tool names and descriptions'),
398
+ limit: z.number().int().min(1).max(50).optional().describe('Max results (default 8)'),
399
+ }),
400
+ },
401
+ ),
402
+ )
335
403
  }
336
404
 
337
405
  // 4. Always available: request_tool_access
@@ -700,6 +700,16 @@ function normalizeStoredRecordInner(
700
700
  return task
701
701
  }
702
702
 
703
+ if (table === 'mcp_servers') {
704
+ const server = value as StoredObject
705
+ // Back-compat: existing servers had no alwaysExpose; match historical behavior
706
+ // where every tool was eagerly bound on every turn.
707
+ if (server.alwaysExpose === undefined) {
708
+ server.alwaysExpose = true
709
+ }
710
+ return server
711
+ }
712
+
703
713
  if (table === 'provider_configs') {
704
714
  const provider = value as StoredObject
705
715
  provider.type = provider.type === 'builtin' ? 'builtin' : 'custom'
@@ -801,6 +811,7 @@ function normalizeStoredRecordInner(
801
811
  if (session.geminiSessionId === undefined) session.geminiSessionId = null
802
812
  // Default copilotSessionId for new field
803
813
  if (session.copilotSessionId === undefined) session.copilotSessionId = null
814
+ if (session.opencodeWebSessionId === undefined) session.opencodeWebSessionId = null
804
815
  if (session.droidSessionId === undefined) session.droidSessionId = null
805
816
  if (session.cursorSessionId === undefined) session.cursorSessionId = null
806
817
  if (session.qwenSessionId === undefined) session.qwenSessionId = null
@@ -86,6 +86,7 @@ export interface Agent {
86
86
  skillIds?: string[] // IDs of pinned managed skills to keep always-on for this agent
87
87
  mcpServerIds?: string[] // IDs of configured MCP servers to inject tools from
88
88
  mcpDisabledTools?: string[] // MCP tool names disabled for this agent (denylist)
89
+ mcpEagerTools?: string[] // Per-agent allowlist of MCP tool names to bind eagerly even when the server's alwaysExpose is false
89
90
  orgChart?: AgentOrgChart | null
90
91
  capabilities?: string[] // e.g. ['frontend', 'screenshots', 'research', 'devops']
91
92
  threadSessionId?: string | null // persistent shortcut chat session for agent-centric UI
package/src/types/misc.ts CHANGED
@@ -629,6 +629,13 @@ export interface McpServerConfig {
629
629
  url?: string // for sse/streamable-http transport
630
630
  env?: Record<string, string> // environment variables
631
631
  headers?: Record<string, string> // HTTP headers for sse/streamable-http
632
+ // Tool-exposure policy — lets users cut token spend from chatty MCP servers.
633
+ // - true (default, back-compat) → every tool is bound on every turn
634
+ // - false → no tools bound; agent uses mcp_tool_search to promote them
635
+ // - string[] (allow-list) → only tools whose names appear here are bound eagerly
636
+ // Regardless of this setting, tools the agent has promoted via mcp_tool_search in the
637
+ // current session are bound too. And per-agent mcpEagerTools provide an explicit override.
638
+ alwaysExpose?: boolean | string[]
632
639
  createdAt: number
633
640
  updatedAt: number
634
641
  }