@swarmclawai/swarmclaw 1.5.64 → 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.
- package/README.md +11 -0
- package/package.json +1 -1
- package/src/app/api/mcp-registry/[slug]/route.ts +31 -0
- package/src/app/api/mcp-registry/route.ts +36 -0
- package/src/app/api/mcp-servers/[id]/test/route.ts +10 -4
- package/src/app/api/mcp-servers/route.test.ts +10 -0
- package/src/cli/index.js +8 -0
- package/src/components/mcp-servers/mcp-server-list.tsx +1 -1
- package/src/components/mcp-servers/registry-browser.tsx +4 -9
- package/src/lib/server/mcp-connection-pool.test.ts +29 -0
- package/src/lib/server/mcp-connection-pool.ts +16 -0
- package/src/lib/server/session-tools/index.ts +33 -2
package/README.md
CHANGED
|
@@ -399,6 +399,17 @@ Operational docs: https://swarmclaw.ai/docs/observability
|
|
|
399
399
|
|
|
400
400
|
## Releases
|
|
401
401
|
|
|
402
|
+
### v1.5.65 Highlights
|
|
403
|
+
|
|
404
|
+
Follow-up hardening on the v1.5.64 work after live-testing the chat-header flows, the MCP connection pool, and the MCP Registry browser. Six concrete bugs fixed in the clear/undo, MCP pool eviction, and registry-browser code paths.
|
|
405
|
+
|
|
406
|
+
- **`clearChatMessages` now resets `opencodeWebSessionId` too.** The snapshot/undo pair already captured and restored it, but `clear` itself left the stale identifier in place — so a fresh opencode-web turn would resume the conversation the user intended to drop. Paired with a matching default in `storage-normalization.ts` so older session records load with `opencodeWebSessionId: null` instead of `undefined`. Regression covered by `clear-route.test.ts`.
|
|
407
|
+
- **Undo toast no longer writes to the wrong chat.** If the user navigated away after clicking Clear, clicking Undo in the toast would inject restored messages into whatever chat was currently open. `chat-area.tsx` now gates the `setMessages` calls on `selectActiveSessionId === targetSessionId`; same guard added to the compact-complete path.
|
|
408
|
+
- **Background MCP status probes no longer evict the connection pool.** Visiting `/mcp-servers` auto-called `POST /api/mcp-servers/:id/test` for every server, which force-disconnected pooled clients that running agents were using mid-turn. Eviction is now gated behind `?reset=1`, which only the explicit **Re-test** button sends. Regression added to `src/app/api/mcp-servers/route.test.ts`.
|
|
409
|
+
- **SwarmDock MCP Registry browser actually works now.** The upstream `swarmdock-api.onrender.com` endpoint emits no CORS headers, so the in-browser `RegistryBrowser` component always failed with `Failed to fetch`. Added `GET /api/mcp-registry` and `GET /api/mcp-registry/:slug` as server-side proxies and rewired the component to call them. Verified in Chrome: 20 servers load, selecting one prefills the New MCP Server sheet with its recommended install command.
|
|
410
|
+
- **`mcp-registry` CLI group.** New commands `swarmclaw mcp-registry search` and `swarmclaw mcp-registry get <slug>` so CLI workflows can pull from the same proxy.
|
|
411
|
+
- **Prior release's MCP tool-evict-on-transport-failure fix** (cherry-picked from user's local branch): connection-class errors from downstream MCP tools now evict the pool entry for the originating server, so the next turn reconnects fresh instead of retrying through a half-broken transport.
|
|
412
|
+
|
|
402
413
|
### v1.5.64 Highlights
|
|
403
414
|
|
|
404
415
|
Two themes this release. First, **context-window management reaches the chat UI**: a live token-usage meter in every chat header, a one-click LLM-backed compaction that keeps the session alive without nuking history, and a redesigned clear flow with a 30-second undo that restores both transcripts and CLI resume IDs. Second, **MCP token spend is now controllable**: per-server `alwaysExpose` policy, per-agent eager-tool overrides, an in-session `mcp_tool_search` promoter, a long-lived connection pool, a token-cost endpoint per server, and a built-in browser for the public SwarmDock MCP registry.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@swarmclawai/swarmclaw",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.65",
|
|
4
4
|
"description": "Build and run autonomous AI agents with OpenClaw, Hermes, multiple model providers, orchestration, delegation, memory, skills, schedules, and chat connectors.",
|
|
5
5
|
"main": "electron-dist/main.js",
|
|
6
6
|
"license": "MIT",
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
|
|
3
|
+
const REGISTRY_API = 'https://swarmdock-api.onrender.com/api/v1/mcp/servers'
|
|
4
|
+
|
|
5
|
+
export async function GET(_req: Request, { params }: { params: Promise<{ slug: string }> }) {
|
|
6
|
+
const { slug } = await params
|
|
7
|
+
if (!slug.trim()) {
|
|
8
|
+
return NextResponse.json({ error: 'slug is required' }, { status: 400 })
|
|
9
|
+
}
|
|
10
|
+
try {
|
|
11
|
+
const upstream = await fetch(`${REGISTRY_API}/${encodeURIComponent(slug)}`, {
|
|
12
|
+
headers: { accept: 'application/json' },
|
|
13
|
+
})
|
|
14
|
+
if (upstream.status === 404) {
|
|
15
|
+
return NextResponse.json({ error: 'Registry server not found' }, { status: 404 })
|
|
16
|
+
}
|
|
17
|
+
if (!upstream.ok) {
|
|
18
|
+
return NextResponse.json(
|
|
19
|
+
{ error: `Server detail returned ${upstream.status}` },
|
|
20
|
+
{ status: 502 },
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
const data = await upstream.json()
|
|
24
|
+
return NextResponse.json(data)
|
|
25
|
+
} catch (err: unknown) {
|
|
26
|
+
return NextResponse.json(
|
|
27
|
+
{ error: err instanceof Error ? err.message : 'Registry unreachable' },
|
|
28
|
+
{ status: 502 },
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
|
|
3
|
+
// Server-side proxy for the public SwarmDock MCP Registry. The upstream API
|
|
4
|
+
// does not emit CORS headers, so the RegistryBrowser component in the browser
|
|
5
|
+
// cannot fetch it directly. This route forwards the search request and its
|
|
6
|
+
// JSON response untouched.
|
|
7
|
+
|
|
8
|
+
const REGISTRY_API = 'https://swarmdock-api.onrender.com/api/v1/mcp/servers'
|
|
9
|
+
|
|
10
|
+
export async function GET(req: Request) {
|
|
11
|
+
const url = new URL(req.url)
|
|
12
|
+
const q = url.searchParams.get('q') ?? ''
|
|
13
|
+
const limitRaw = url.searchParams.get('limit') ?? '20'
|
|
14
|
+
const limit = Math.max(1, Math.min(Number.parseInt(limitRaw, 10) || 20, 50))
|
|
15
|
+
const qs = new URLSearchParams({ limit: String(limit) })
|
|
16
|
+
if (q.trim()) qs.set('q', q.trim())
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
const upstream = await fetch(`${REGISTRY_API}?${qs.toString()}`, {
|
|
20
|
+
headers: { accept: 'application/json' },
|
|
21
|
+
})
|
|
22
|
+
if (!upstream.ok) {
|
|
23
|
+
return NextResponse.json(
|
|
24
|
+
{ error: `Registry returned ${upstream.status}` },
|
|
25
|
+
{ status: 502 },
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
const data = await upstream.json()
|
|
29
|
+
return NextResponse.json(data)
|
|
30
|
+
} catch (err: unknown) {
|
|
31
|
+
return NextResponse.json(
|
|
32
|
+
{ error: err instanceof Error ? err.message : 'Registry unreachable' },
|
|
33
|
+
{ status: 502 },
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -5,15 +5,21 @@ import { connectMcpServer, mcpToolsToLangChain, disconnectMcpServer } from '@/li
|
|
|
5
5
|
import { evictMcpClient } from '@/lib/server/mcp-connection-pool'
|
|
6
6
|
import { errorMessage } from '@/lib/shared-utils'
|
|
7
7
|
|
|
8
|
-
export async function POST(
|
|
8
|
+
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
9
9
|
const { id } = await params
|
|
10
10
|
const servers = loadMcpServers()
|
|
11
11
|
const server = servers[id]
|
|
12
12
|
if (!server) return notFound()
|
|
13
13
|
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
|
|
14
|
+
// Only evict the pool when the caller explicitly asks for a reset (e.g. the
|
|
15
|
+
// "Re-test" button). Background probes from the server list view skip this
|
|
16
|
+
// so they don't disconnect pooled clients that running agents are using
|
|
17
|
+
// mid-turn. Pool eviction on config change is handled by the PUT route.
|
|
18
|
+
const url = new URL(req.url)
|
|
19
|
+
const reset = url.searchParams.get('reset') === '1' || url.searchParams.get('reset') === 'true'
|
|
20
|
+
if (reset) {
|
|
21
|
+
await evictMcpClient(id)
|
|
22
|
+
}
|
|
17
23
|
|
|
18
24
|
try {
|
|
19
25
|
const { client, transport } = await connectMcpServer(server)
|
|
@@ -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
|
@@ -372,6 +372,14 @@ const COMMAND_GROUPS = [
|
|
|
372
372
|
cmd('invoke', 'POST', '/mcp-servers/:id/invoke', 'Invoke an MCP tool on a server', { expectsJsonBody: true }),
|
|
373
373
|
],
|
|
374
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
|
+
},
|
|
375
383
|
{
|
|
376
384
|
name: 'memories',
|
|
377
385
|
description: 'Alias of memory command group',
|
|
@@ -188,7 +188,7 @@ export function McpServerList({ inSidebar }: { inSidebar?: boolean }) {
|
|
|
188
188
|
e.stopPropagation()
|
|
189
189
|
setStatuses((prev) => ({ ...prev, [id]: { ok: false, loading: true } }))
|
|
190
190
|
try {
|
|
191
|
-
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`)
|
|
192
192
|
if (!mountedRef.current) return
|
|
193
193
|
setStatuses((prev) => ({ ...prev, [id]: { ok: res.ok, tools: res.tools, error: res.error, loading: false } }))
|
|
194
194
|
if (res.ok) toast.success('Connection test passed')
|
|
@@ -11,8 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { useEffect, useState } from 'react'
|
|
14
|
-
|
|
15
|
-
const REGISTRY_API = 'https://swarmdock-api.onrender.com/api/v1/mcp/servers'
|
|
14
|
+
import { api } from '@/lib/app/api-client'
|
|
16
15
|
|
|
17
16
|
export interface RegistryPrefill {
|
|
18
17
|
name: string
|
|
@@ -100,10 +99,8 @@ export function RegistryBrowser({
|
|
|
100
99
|
setError(null)
|
|
101
100
|
try {
|
|
102
101
|
const qs = query ? `?q=${encodeURIComponent(query)}&limit=20` : '?limit=20'
|
|
103
|
-
const
|
|
104
|
-
if (!
|
|
105
|
-
const data = await res.json() as { servers: RegistryServer[] }
|
|
106
|
-
if (!cancelled) setServers(data.servers)
|
|
102
|
+
const data = await api<{ servers: RegistryServer[] }>('GET', `/mcp-registry${qs}`)
|
|
103
|
+
if (!cancelled) setServers(data.servers ?? [])
|
|
107
104
|
} catch (err) {
|
|
108
105
|
if (!cancelled) setError(err instanceof Error ? err.message : 'Failed to load registry')
|
|
109
106
|
} finally {
|
|
@@ -120,9 +117,7 @@ export function RegistryBrowser({
|
|
|
120
117
|
const handleSelect = async (slug: string) => {
|
|
121
118
|
setSelecting(slug)
|
|
122
119
|
try {
|
|
123
|
-
const
|
|
124
|
-
if (!res.ok) throw new Error(`Server detail returned ${res.status}`)
|
|
125
|
-
const detail = await res.json() as RegistryDetail
|
|
120
|
+
const detail = await api<RegistryDetail>('GET', `/mcp-registry/${encodeURIComponent(slug)}`)
|
|
126
121
|
const prefill = installToPrefill(detail)
|
|
127
122
|
if (!prefill) {
|
|
128
123
|
setError('This server has no installation method SwarmClaw can consume yet.')
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
evictAllMcpClients,
|
|
7
7
|
evictMcpClient,
|
|
8
8
|
getOrConnectMcpClient,
|
|
9
|
+
isConnectionLikeError,
|
|
9
10
|
isPooled,
|
|
10
11
|
poolSize,
|
|
11
12
|
} from './mcp-connection-pool'
|
|
@@ -96,3 +97,31 @@ describe('mcp-connection-pool', () => {
|
|
|
96
97
|
assert.equal(poolSize(), 0)
|
|
97
98
|
})
|
|
98
99
|
})
|
|
100
|
+
|
|
101
|
+
describe('isConnectionLikeError', () => {
|
|
102
|
+
it('returns true for known transport-level error codes', () => {
|
|
103
|
+
const err = Object.assign(new Error('epipe'), { code: 'EPIPE' })
|
|
104
|
+
assert.equal(isConnectionLikeError(err), true)
|
|
105
|
+
const err2 = Object.assign(new Error('reset'), { code: 'ECONNRESET' })
|
|
106
|
+
assert.equal(isConnectionLikeError(err2), true)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('returns true on connection-closed messages', () => {
|
|
110
|
+
assert.equal(isConnectionLikeError(new Error('Connection closed')), true)
|
|
111
|
+
assert.equal(isConnectionLikeError(new Error('MCP server not connected')), true)
|
|
112
|
+
assert.equal(isConnectionLikeError(new Error('child process exited')), true)
|
|
113
|
+
assert.equal(isConnectionLikeError(new Error('socket hang up')), true)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('returns false for ordinary tool-level errors', () => {
|
|
117
|
+
assert.equal(isConnectionLikeError(new Error('GitHub token is invalid')), false)
|
|
118
|
+
assert.equal(isConnectionLikeError(new Error('File not found: /nope')), false)
|
|
119
|
+
assert.equal(isConnectionLikeError(new Error('schema validation failed')), false)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('returns false for non-error inputs', () => {
|
|
123
|
+
assert.equal(isConnectionLikeError(null), false)
|
|
124
|
+
assert.equal(isConnectionLikeError(undefined), false)
|
|
125
|
+
assert.equal(isConnectionLikeError(''), false)
|
|
126
|
+
})
|
|
127
|
+
})
|
|
@@ -132,3 +132,19 @@ async function safeDisconnect(entry: PoolEntry): Promise<void> {
|
|
|
132
132
|
/* ignore — we're tearing down anyway */
|
|
133
133
|
}
|
|
134
134
|
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Heuristic: does this error look like the pooled connection is dead (vs. a
|
|
138
|
+
* normal tool-level error the caller should surface)? Conservative by design —
|
|
139
|
+
* we only evict on well-known transport-level signatures so a "your API key is
|
|
140
|
+
* wrong" error from an MCP tool doesn't force a reconnect storm.
|
|
141
|
+
*/
|
|
142
|
+
export function isConnectionLikeError(err: unknown): boolean {
|
|
143
|
+
if (!err) return false
|
|
144
|
+
const code = typeof err === 'object' && err && 'code' in err ? String((err as { code: unknown }).code ?? '') : ''
|
|
145
|
+
if (code && /^(ECONNREFUSED|ECONNRESET|EPIPE|EHOSTUNREACH|ETIMEDOUT|ENOTFOUND|ECONNABORTED)$/i.test(code)) {
|
|
146
|
+
return true
|
|
147
|
+
}
|
|
148
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
149
|
+
return /connection closed|transport closed|server has closed|process exited|child exited|mcp server not connected|read ECONN|write EPIPE|socket hang up|stream closed|unexpected end of (?:json|input|stream)/i.test(msg)
|
|
150
|
+
}
|
|
@@ -62,7 +62,7 @@ import {
|
|
|
62
62
|
shouldExposeMcpTool,
|
|
63
63
|
type DiscoveredTool,
|
|
64
64
|
} from '../mcp-gateway-runtime'
|
|
65
|
-
import { getOrConnectMcpClient } from '../mcp-connection-pool'
|
|
65
|
+
import { getOrConnectMcpClient, evictMcpClient, isConnectionLikeError } from '../mcp-connection-pool'
|
|
66
66
|
import {
|
|
67
67
|
getEnabledCapabilitySelection,
|
|
68
68
|
isExternalExtensionId,
|
|
@@ -94,6 +94,37 @@ function inferBareName(langChainName: string, serverName: string): string {
|
|
|
94
94
|
return langChainName.startsWith(prefix) ? langChainName.slice(prefix.length) : langChainName
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Wraps an MCP-sourced LangChain tool so connection-class failures (stdio pipe
|
|
99
|
+
* closed, HTTP reset, etc.) evict the pool entry, letting the next turn
|
|
100
|
+
* rebuild the client fresh. Non-connection errors (validation, tool logic,
|
|
101
|
+
* auth) propagate unchanged — we trust the downstream's isError signal.
|
|
102
|
+
*/
|
|
103
|
+
function wrapMcpToolWithPoolEviction(
|
|
104
|
+
inner: StructuredToolInterface,
|
|
105
|
+
serverId: string,
|
|
106
|
+
): StructuredToolInterface {
|
|
107
|
+
const wrappedCallback = async (args: unknown): Promise<unknown> => {
|
|
108
|
+
try {
|
|
109
|
+
return await inner.invoke(args as Record<string, unknown>)
|
|
110
|
+
} catch (err: unknown) {
|
|
111
|
+
if (isConnectionLikeError(err)) {
|
|
112
|
+
void evictMcpClient(serverId).catch(() => undefined)
|
|
113
|
+
log.warn('session-tools', `MCP tool "${inner.name}" connection error — evicted pool entry for ${serverId}`, {
|
|
114
|
+
error: errorMessage(err),
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
throw err
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return tool(wrappedCallback, {
|
|
121
|
+
name: inner.name,
|
|
122
|
+
description: inner.description,
|
|
123
|
+
// Re-use the inner tool's zod schema so shape/validation is identical.
|
|
124
|
+
schema: (inner as unknown as { schema: z.ZodType }).schema,
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
|
|
97
128
|
export async function buildSessionTools(cwd: string, enabledExtensions: string[], ctx?: ToolContext): Promise<SessionToolsResult> {
|
|
98
129
|
const tools: StructuredToolInterface[] = []
|
|
99
130
|
const cleanupFns: (() => Promise<void>)[] = []
|
|
@@ -354,7 +385,7 @@ export async function buildSessionTools(cwd: string, enabledExtensions: string[]
|
|
|
354
385
|
})
|
|
355
386
|
if (!shouldBind) continue
|
|
356
387
|
toolToExtensionMap[t.name] = `mcp:${serverId}`
|
|
357
|
-
tools.push(t)
|
|
388
|
+
tools.push(wrapMcpToolWithPoolEviction(t, serverId))
|
|
358
389
|
}
|
|
359
390
|
} catch (err: unknown) {
|
|
360
391
|
log.warn('session-tools', `Failed to connect MCP server "${config.name}"`, { serverId, error: errorMessage(err) })
|