@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.
- package/README.md +17 -0
- package/package.json +2 -2
- package/src/app/api/chats/[id]/clear/route.ts +7 -3
- package/src/app/api/chats/[id]/clear/undo/route.ts +23 -0
- package/src/app/api/chats/[id]/compact/route.ts +72 -0
- package/src/app/api/chats/[id]/context-status/route.ts +21 -0
- package/src/app/api/chats/clear-route.test.ts +121 -0
- package/src/app/api/chats/compact-route.test.ts +70 -0
- package/src/app/api/chats/context-status-route.test.ts +68 -0
- package/src/app/api/mcp-servers/[id]/route.ts +5 -0
- package/src/app/api/mcp-servers/[id]/test/route.ts +5 -0
- package/src/app/api/mcp-servers/[id]/tools-info/route.ts +75 -0
- package/src/cli/index.js +5 -1
- package/src/cli/spec.js +4 -1
- package/src/components/chat/chat-area.tsx +62 -6
- package/src/components/chat/chat-header.tsx +13 -1
- package/src/components/chat/context-meter-badge.tsx +227 -0
- package/src/components/mcp-servers/mcp-server-list.tsx +56 -0
- package/src/components/mcp-servers/mcp-server-sheet.tsx +202 -1
- package/src/components/mcp-servers/registry-browser.tsx +224 -0
- package/src/lib/chat/chats.ts +37 -1
- package/src/lib/server/chats/chat-session-service.ts +75 -0
- package/src/lib/server/chats/clear-undo-snapshots.test.ts +107 -0
- package/src/lib/server/chats/clear-undo-snapshots.ts +92 -0
- package/src/lib/server/mcp-connection-pool.test.ts +98 -0
- package/src/lib/server/mcp-connection-pool.ts +134 -0
- package/src/lib/server/mcp-gateway-runtime.test.ts +177 -0
- package/src/lib/server/mcp-gateway-runtime.ts +138 -0
- package/src/lib/server/session-tools/index.ts +83 -15
- package/src/lib/server/storage-normalization.ts +11 -0
- package/src/types/agent.ts +1 -0
- 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 {
|
|
316
|
-
const conn = await
|
|
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 (
|
|
321
|
-
|
|
322
|
-
|
|
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
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
package/src/types/agent.ts
CHANGED
|
@@ -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
|
}
|