@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.
- package/README.md +28 -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-registry/[slug]/route.ts +31 -0
- package/src/app/api/mcp-registry/route.ts +36 -0
- package/src/app/api/mcp-servers/[id]/route.ts +5 -0
- package/src/app/api/mcp-servers/[id]/test/route.ts +12 -1
- package/src/app/api/mcp-servers/[id]/tools-info/route.ts +75 -0
- package/src/app/api/mcp-servers/route.test.ts +10 -0
- package/src/cli/index.js +13 -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 +57 -1
- package/src/components/mcp-servers/mcp-server-sheet.tsx +202 -1
- package/src/components/mcp-servers/registry-browser.tsx +219 -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 +127 -0
- package/src/lib/server/mcp-connection-pool.ts +150 -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 +114 -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,107 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { afterEach, describe, it } from 'node:test'
|
|
3
|
+
|
|
4
|
+
import type { Message } from '@/types'
|
|
5
|
+
import {
|
|
6
|
+
CLEAR_UNDO_TTL_MS,
|
|
7
|
+
__resetClearUndoSnapshotsForTests,
|
|
8
|
+
consumeClearUndoSnapshot,
|
|
9
|
+
recordClearUndoSnapshot,
|
|
10
|
+
type ClearUndoCliIds,
|
|
11
|
+
} from './clear-undo-snapshots'
|
|
12
|
+
|
|
13
|
+
function makeMsg(text: string): Message {
|
|
14
|
+
return { role: 'user', text, time: Date.now() }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const emptyCli: ClearUndoCliIds = {
|
|
18
|
+
claudeSessionId: null,
|
|
19
|
+
codexThreadId: null,
|
|
20
|
+
opencodeSessionId: null,
|
|
21
|
+
opencodeWebSessionId: null,
|
|
22
|
+
geminiSessionId: null,
|
|
23
|
+
copilotSessionId: null,
|
|
24
|
+
droidSessionId: null,
|
|
25
|
+
cursorSessionId: null,
|
|
26
|
+
qwenSessionId: null,
|
|
27
|
+
acpSessionId: null,
|
|
28
|
+
delegateResumeIds: null,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('clear-undo-snapshots', () => {
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
__resetClearUndoSnapshotsForTests()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('records and consumes a snapshot within the TTL window', () => {
|
|
37
|
+
const sessionId = 'sess_test_1'
|
|
38
|
+
const messages = [makeMsg('hello'), makeMsg('world')]
|
|
39
|
+
const { token, expiresAt } = recordClearUndoSnapshot({ sessionId, messages, cli: emptyCli })
|
|
40
|
+
assert.match(token, /^undo_/)
|
|
41
|
+
assert.ok(expiresAt > Date.now())
|
|
42
|
+
const snapshot = consumeClearUndoSnapshot({ token, sessionId })
|
|
43
|
+
assert.ok(snapshot)
|
|
44
|
+
assert.equal(snapshot.messages.length, 2)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('single-use: a consumed snapshot cannot be consumed again', () => {
|
|
48
|
+
const sessionId = 'sess_test_2'
|
|
49
|
+
const { token } = recordClearUndoSnapshot({
|
|
50
|
+
sessionId,
|
|
51
|
+
messages: [makeMsg('hi')],
|
|
52
|
+
cli: emptyCli,
|
|
53
|
+
})
|
|
54
|
+
const first = consumeClearUndoSnapshot({ token, sessionId })
|
|
55
|
+
assert.ok(first)
|
|
56
|
+
const second = consumeClearUndoSnapshot({ token, sessionId })
|
|
57
|
+
assert.equal(second, null)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('rejects a consume with a mismatched sessionId', () => {
|
|
61
|
+
const { token } = recordClearUndoSnapshot({
|
|
62
|
+
sessionId: 'sess_owner',
|
|
63
|
+
messages: [makeMsg('hi')],
|
|
64
|
+
cli: emptyCli,
|
|
65
|
+
})
|
|
66
|
+
const hijacked = consumeClearUndoSnapshot({ token, sessionId: 'sess_other' })
|
|
67
|
+
assert.equal(hijacked, null)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('rejects an expired snapshot and sweeps it from the store', () => {
|
|
71
|
+
const base = 1_000_000
|
|
72
|
+
const { token } = recordClearUndoSnapshot({
|
|
73
|
+
sessionId: 'sess_expire',
|
|
74
|
+
messages: [makeMsg('stale')],
|
|
75
|
+
cli: emptyCli,
|
|
76
|
+
now: base,
|
|
77
|
+
})
|
|
78
|
+
const expired = consumeClearUndoSnapshot({
|
|
79
|
+
token,
|
|
80
|
+
sessionId: 'sess_expire',
|
|
81
|
+
now: base + CLEAR_UNDO_TTL_MS + 1,
|
|
82
|
+
})
|
|
83
|
+
assert.equal(expired, null)
|
|
84
|
+
// Same token is now gone from the store entirely
|
|
85
|
+
const again = consumeClearUndoSnapshot({ token, sessionId: 'sess_expire', now: base })
|
|
86
|
+
assert.equal(again, null)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('preserves CLI session IDs and delegateResumeIds across record/consume', () => {
|
|
90
|
+
const cli: ClearUndoCliIds = {
|
|
91
|
+
...emptyCli,
|
|
92
|
+
claudeSessionId: 'cs_abc',
|
|
93
|
+
codexThreadId: 'cx_def',
|
|
94
|
+
delegateResumeIds: { claudeCode: 'resume_123', codex: null },
|
|
95
|
+
}
|
|
96
|
+
const { token } = recordClearUndoSnapshot({
|
|
97
|
+
sessionId: 'sess_cli',
|
|
98
|
+
messages: [],
|
|
99
|
+
cli,
|
|
100
|
+
})
|
|
101
|
+
const snapshot = consumeClearUndoSnapshot({ token, sessionId: 'sess_cli' })
|
|
102
|
+
assert.ok(snapshot)
|
|
103
|
+
assert.equal(snapshot.cli.claudeSessionId, 'cs_abc')
|
|
104
|
+
assert.equal(snapshot.cli.codexThreadId, 'cx_def')
|
|
105
|
+
assert.equal(snapshot.cli.delegateResumeIds?.claudeCode, 'resume_123')
|
|
106
|
+
})
|
|
107
|
+
})
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { Message } from '@/types'
|
|
2
|
+
import { hmrSingleton } from '@/lib/shared-utils'
|
|
3
|
+
import { genId } from '@/lib/id'
|
|
4
|
+
|
|
5
|
+
export const CLEAR_UNDO_TTL_MS = 30_000
|
|
6
|
+
const MAX_SNAPSHOTS = 200
|
|
7
|
+
|
|
8
|
+
export interface ClearUndoCliIds {
|
|
9
|
+
claudeSessionId: string | null
|
|
10
|
+
codexThreadId: string | null
|
|
11
|
+
opencodeSessionId: string | null
|
|
12
|
+
opencodeWebSessionId: string | null
|
|
13
|
+
geminiSessionId: string | null
|
|
14
|
+
copilotSessionId: string | null
|
|
15
|
+
droidSessionId: string | null
|
|
16
|
+
cursorSessionId: string | null
|
|
17
|
+
qwenSessionId: string | null
|
|
18
|
+
acpSessionId: string | null
|
|
19
|
+
delegateResumeIds?: Record<string, string | null> | null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ClearUndoSnapshot {
|
|
23
|
+
sessionId: string
|
|
24
|
+
messages: Message[]
|
|
25
|
+
cli: ClearUndoCliIds
|
|
26
|
+
createdAt: number
|
|
27
|
+
expiresAt: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const snapshots = hmrSingleton(
|
|
31
|
+
'swarmclaw:clearUndoSnapshots',
|
|
32
|
+
() => new Map<string, ClearUndoSnapshot>(),
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
function sweepExpired(now: number): void {
|
|
36
|
+
if (snapshots.size === 0) return
|
|
37
|
+
for (const [token, snapshot] of snapshots) {
|
|
38
|
+
if (snapshot.expiresAt <= now) snapshots.delete(token)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function enforceCap(): void {
|
|
43
|
+
if (snapshots.size <= MAX_SNAPSHOTS) return
|
|
44
|
+
const entries = [...snapshots.entries()].sort((a, b) => a[1].createdAt - b[1].createdAt)
|
|
45
|
+
const excess = snapshots.size - MAX_SNAPSHOTS
|
|
46
|
+
for (let i = 0; i < excess; i++) snapshots.delete(entries[i][0])
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function recordClearUndoSnapshot(params: {
|
|
50
|
+
sessionId: string
|
|
51
|
+
messages: Message[]
|
|
52
|
+
cli: ClearUndoCliIds
|
|
53
|
+
now?: number
|
|
54
|
+
}): { token: string; expiresAt: number } {
|
|
55
|
+
const now = typeof params.now === 'number' ? params.now : Date.now()
|
|
56
|
+
sweepExpired(now)
|
|
57
|
+
const token = `undo_${genId(8)}`
|
|
58
|
+
const expiresAt = now + CLEAR_UNDO_TTL_MS
|
|
59
|
+
snapshots.set(token, {
|
|
60
|
+
sessionId: params.sessionId,
|
|
61
|
+
messages: params.messages,
|
|
62
|
+
cli: params.cli,
|
|
63
|
+
createdAt: now,
|
|
64
|
+
expiresAt,
|
|
65
|
+
})
|
|
66
|
+
enforceCap()
|
|
67
|
+
return { token, expiresAt }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function consumeClearUndoSnapshot(params: {
|
|
71
|
+
token: string
|
|
72
|
+
sessionId: string
|
|
73
|
+
now?: number
|
|
74
|
+
}): ClearUndoSnapshot | null {
|
|
75
|
+
const now = typeof params.now === 'number' ? params.now : Date.now()
|
|
76
|
+
sweepExpired(now)
|
|
77
|
+
const snapshot = snapshots.get(params.token)
|
|
78
|
+
if (!snapshot) return null
|
|
79
|
+
if (snapshot.sessionId !== params.sessionId) {
|
|
80
|
+
return null
|
|
81
|
+
}
|
|
82
|
+
if (snapshot.expiresAt <= now) {
|
|
83
|
+
snapshots.delete(params.token)
|
|
84
|
+
return null
|
|
85
|
+
}
|
|
86
|
+
snapshots.delete(params.token)
|
|
87
|
+
return snapshot
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function __resetClearUndoSnapshotsForTests(): void {
|
|
91
|
+
snapshots.clear()
|
|
92
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { describe, it, beforeEach, after } from 'node:test'
|
|
3
|
+
import type { McpServerConfig } from '@/types'
|
|
4
|
+
import {
|
|
5
|
+
__setPoolConnector,
|
|
6
|
+
evictAllMcpClients,
|
|
7
|
+
evictMcpClient,
|
|
8
|
+
getOrConnectMcpClient,
|
|
9
|
+
isConnectionLikeError,
|
|
10
|
+
isPooled,
|
|
11
|
+
poolSize,
|
|
12
|
+
} from './mcp-connection-pool'
|
|
13
|
+
|
|
14
|
+
let connectCalls = 0
|
|
15
|
+
let disconnectCalls = 0
|
|
16
|
+
|
|
17
|
+
function server(id: string, overrides: Partial<McpServerConfig> = {}): McpServerConfig {
|
|
18
|
+
return {
|
|
19
|
+
id,
|
|
20
|
+
name: `srv-${id}`,
|
|
21
|
+
transport: 'stdio',
|
|
22
|
+
command: 'echo',
|
|
23
|
+
args: [],
|
|
24
|
+
createdAt: 0,
|
|
25
|
+
updatedAt: 0,
|
|
26
|
+
...overrides,
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('mcp-connection-pool', () => {
|
|
31
|
+
beforeEach(async () => {
|
|
32
|
+
await evictAllMcpClients()
|
|
33
|
+
connectCalls = 0
|
|
34
|
+
disconnectCalls = 0
|
|
35
|
+
__setPoolConnector({
|
|
36
|
+
connect: async () => {
|
|
37
|
+
connectCalls += 1
|
|
38
|
+
return {
|
|
39
|
+
client: { seq: connectCalls } as never,
|
|
40
|
+
transport: { seq: connectCalls } as never,
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
disconnect: async () => {
|
|
44
|
+
disconnectCalls += 1
|
|
45
|
+
},
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
after(() => {
|
|
50
|
+
__setPoolConnector()
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('connects once per server and reuses on subsequent calls', async () => {
|
|
54
|
+
const a = await getOrConnectMcpClient(server('a'))
|
|
55
|
+
const b = await getOrConnectMcpClient(server('a'))
|
|
56
|
+
assert.equal(connectCalls, 1)
|
|
57
|
+
assert.equal(a.client, b.client)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('reconnects when the config fingerprint changes', async () => {
|
|
61
|
+
await getOrConnectMcpClient(server('a', { args: ['--v1'] }))
|
|
62
|
+
await getOrConnectMcpClient(server('a', { args: ['--v2'] }))
|
|
63
|
+
assert.equal(connectCalls, 2)
|
|
64
|
+
assert.equal(disconnectCalls, 1)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('evictMcpClient disconnects and lets the next call reconnect', async () => {
|
|
68
|
+
await getOrConnectMcpClient(server('a'))
|
|
69
|
+
await evictMcpClient('a')
|
|
70
|
+
assert.equal(disconnectCalls, 1)
|
|
71
|
+
assert.equal(isPooled('a'), false)
|
|
72
|
+
await getOrConnectMcpClient(server('a'))
|
|
73
|
+
assert.equal(connectCalls, 2)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('coalesces concurrent connects', async () => {
|
|
77
|
+
const [a, b] = await Promise.all([
|
|
78
|
+
getOrConnectMcpClient(server('dup')),
|
|
79
|
+
getOrConnectMcpClient(server('dup')),
|
|
80
|
+
])
|
|
81
|
+
assert.equal(connectCalls, 1)
|
|
82
|
+
assert.equal(a.client, b.client)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('tracks multiple distinct servers independently', async () => {
|
|
86
|
+
await getOrConnectMcpClient(server('a'))
|
|
87
|
+
await getOrConnectMcpClient(server('b'))
|
|
88
|
+
assert.equal(poolSize(), 2)
|
|
89
|
+
assert.equal(connectCalls, 2)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('evictAll clears everything', async () => {
|
|
93
|
+
await getOrConnectMcpClient(server('a'))
|
|
94
|
+
await getOrConnectMcpClient(server('b'))
|
|
95
|
+
await evictAllMcpClients()
|
|
96
|
+
assert.equal(disconnectCalls, 2)
|
|
97
|
+
assert.equal(poolSize(), 0)
|
|
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
|
+
})
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import type { McpServerConfig } from '@/types'
|
|
2
|
+
import { hmrSingleton } from '@/lib/shared-utils'
|
|
3
|
+
import { connectMcpServer, disconnectMcpServer } from './mcp-client'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Long-lived MCP client pool. Pre-connection took ~100–500 ms per downstream
|
|
7
|
+
* per turn; that's multiplied by (servers × chat turns) for every agent.
|
|
8
|
+
* The pool reuses a single Client/transport per server for the whole process
|
|
9
|
+
* lifetime, re-connecting only when the server's config fingerprint changes
|
|
10
|
+
* or when an explicit evict is requested (e.g. from the `/test` endpoint).
|
|
11
|
+
*
|
|
12
|
+
* State lives on `globalThis` via hmrSingleton so Next.js HMR reloads don't
|
|
13
|
+
* leak child processes (see CLAUDE.md §"hmrSingleton").
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
interface PoolEntry {
|
|
17
|
+
client: Awaited<ReturnType<typeof connectMcpServer>>['client']
|
|
18
|
+
transport: Awaited<ReturnType<typeof connectMcpServer>>['transport']
|
|
19
|
+
configFingerprint: string
|
|
20
|
+
connectedAt: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type Connector = (config: McpServerConfig) => Promise<{
|
|
24
|
+
client: PoolEntry['client']
|
|
25
|
+
transport: PoolEntry['transport']
|
|
26
|
+
}>
|
|
27
|
+
|
|
28
|
+
type Disconnector = (client: PoolEntry['client'], transport: PoolEntry['transport']) => Promise<void>
|
|
29
|
+
|
|
30
|
+
interface PoolState {
|
|
31
|
+
entries: Map<string, PoolEntry>
|
|
32
|
+
inflight: Map<string, Promise<PoolEntry>>
|
|
33
|
+
connector: Connector
|
|
34
|
+
disconnector: Disconnector
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const pool = hmrSingleton<PoolState>('mcpConnectionPool', () => ({
|
|
38
|
+
entries: new Map<string, PoolEntry>(),
|
|
39
|
+
inflight: new Map<string, Promise<PoolEntry>>(),
|
|
40
|
+
connector: connectMcpServer,
|
|
41
|
+
disconnector: disconnectMcpServer,
|
|
42
|
+
}))
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Test-only hook. Swap the connect/disconnect functions with a fake so tests
|
|
46
|
+
* can exercise pool behavior (caching, fingerprinting, coalescing, eviction)
|
|
47
|
+
* without spawning child processes. Pass `undefined` to restore defaults.
|
|
48
|
+
*/
|
|
49
|
+
export function __setPoolConnector(opts: {
|
|
50
|
+
connect?: Connector
|
|
51
|
+
disconnect?: Disconnector
|
|
52
|
+
} = {}): void {
|
|
53
|
+
pool.connector = opts.connect ?? connectMcpServer
|
|
54
|
+
pool.disconnector = opts.disconnect ?? disconnectMcpServer
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function configFingerprint(c: McpServerConfig): string {
|
|
58
|
+
return JSON.stringify({
|
|
59
|
+
t: c.transport,
|
|
60
|
+
cmd: c.command,
|
|
61
|
+
args: c.args,
|
|
62
|
+
cwd: c.cwd,
|
|
63
|
+
url: c.url,
|
|
64
|
+
env: c.env,
|
|
65
|
+
headers: c.headers,
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function getOrConnectMcpClient(
|
|
70
|
+
config: McpServerConfig,
|
|
71
|
+
): Promise<{ client: PoolEntry['client']; transport: PoolEntry['transport'] }> {
|
|
72
|
+
const existing = pool.entries.get(config.id)
|
|
73
|
+
const fp = configFingerprint(config)
|
|
74
|
+
if (existing && existing.configFingerprint === fp) {
|
|
75
|
+
return { client: existing.client, transport: existing.transport }
|
|
76
|
+
}
|
|
77
|
+
// Config changed (or first connect) — drop any stale entry.
|
|
78
|
+
if (existing) {
|
|
79
|
+
await safeDisconnect(existing)
|
|
80
|
+
pool.entries.delete(config.id)
|
|
81
|
+
}
|
|
82
|
+
// Coalesce concurrent connect attempts for the same server id.
|
|
83
|
+
const inflight = pool.inflight.get(config.id)
|
|
84
|
+
if (inflight) {
|
|
85
|
+
const entry = await inflight
|
|
86
|
+
return { client: entry.client, transport: entry.transport }
|
|
87
|
+
}
|
|
88
|
+
const promise = (async () => {
|
|
89
|
+
const { client, transport } = await pool.connector(config)
|
|
90
|
+
const entry: PoolEntry = {
|
|
91
|
+
client,
|
|
92
|
+
transport,
|
|
93
|
+
configFingerprint: fp,
|
|
94
|
+
connectedAt: Date.now(),
|
|
95
|
+
}
|
|
96
|
+
pool.entries.set(config.id, entry)
|
|
97
|
+
return entry
|
|
98
|
+
})()
|
|
99
|
+
pool.inflight.set(config.id, promise)
|
|
100
|
+
try {
|
|
101
|
+
const entry = await promise
|
|
102
|
+
return { client: entry.client, transport: entry.transport }
|
|
103
|
+
} finally {
|
|
104
|
+
pool.inflight.delete(config.id)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function evictMcpClient(serverId: string): Promise<void> {
|
|
109
|
+
const entry = pool.entries.get(serverId)
|
|
110
|
+
if (!entry) return
|
|
111
|
+
pool.entries.delete(serverId)
|
|
112
|
+
await safeDisconnect(entry)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function evictAllMcpClients(): Promise<void> {
|
|
116
|
+
const ids = Array.from(pool.entries.keys())
|
|
117
|
+
await Promise.all(ids.map((id) => evictMcpClient(id)))
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function poolSize(): number {
|
|
121
|
+
return pool.entries.size
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function isPooled(serverId: string): boolean {
|
|
125
|
+
return pool.entries.has(serverId)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function safeDisconnect(entry: PoolEntry): Promise<void> {
|
|
129
|
+
try {
|
|
130
|
+
await pool.disconnector(entry.client, entry.transport)
|
|
131
|
+
} catch {
|
|
132
|
+
/* ignore — we're tearing down anyway */
|
|
133
|
+
}
|
|
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
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { describe, it, beforeEach } from 'node:test'
|
|
3
|
+
import type { McpServerConfig } from '@/types'
|
|
4
|
+
import {
|
|
5
|
+
getPromoter,
|
|
6
|
+
clearPromoter,
|
|
7
|
+
recordDiscoveredTools,
|
|
8
|
+
searchDiscoveredTools,
|
|
9
|
+
shouldExposeMcpTool,
|
|
10
|
+
SessionToolPromoter,
|
|
11
|
+
type DiscoveredTool,
|
|
12
|
+
} from './mcp-gateway-runtime'
|
|
13
|
+
|
|
14
|
+
function baseServer(overrides: Partial<McpServerConfig> = {}): McpServerConfig {
|
|
15
|
+
return {
|
|
16
|
+
id: 'srv_1',
|
|
17
|
+
name: 'fs',
|
|
18
|
+
transport: 'stdio',
|
|
19
|
+
command: 'npx',
|
|
20
|
+
args: ['-y', 'thing'],
|
|
21
|
+
createdAt: 0,
|
|
22
|
+
updatedAt: 0,
|
|
23
|
+
...overrides,
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('SessionToolPromoter', () => {
|
|
28
|
+
it('remembers promoted names', () => {
|
|
29
|
+
const p = new SessionToolPromoter()
|
|
30
|
+
assert.equal(p.allow('mcp_fs_read'), false)
|
|
31
|
+
p.promote('mcp_fs_read')
|
|
32
|
+
assert.equal(p.allow('mcp_fs_read'), true)
|
|
33
|
+
assert.deepEqual(p.promoted(), ['mcp_fs_read'])
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('clears', () => {
|
|
37
|
+
const p = new SessionToolPromoter()
|
|
38
|
+
p.promoteMany(['a', 'b'])
|
|
39
|
+
p.clear()
|
|
40
|
+
assert.deepEqual(p.promoted(), [])
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
describe('getPromoter', () => {
|
|
45
|
+
it('returns a stable instance per sessionId', () => {
|
|
46
|
+
const s1 = 'sess_' + Math.random().toString(36).slice(2)
|
|
47
|
+
const s2 = 'sess_' + Math.random().toString(36).slice(2)
|
|
48
|
+
const p1 = getPromoter(s1)
|
|
49
|
+
const p2 = getPromoter(s2)
|
|
50
|
+
const p1Again = getPromoter(s1)
|
|
51
|
+
assert.equal(p1, p1Again)
|
|
52
|
+
assert.notEqual(p1, p2)
|
|
53
|
+
clearPromoter(s1)
|
|
54
|
+
clearPromoter(s2)
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
describe('shouldExposeMcpTool', () => {
|
|
59
|
+
it('binds everything when alwaysExpose is undefined (back-compat)', () => {
|
|
60
|
+
const server = baseServer()
|
|
61
|
+
assert.equal(
|
|
62
|
+
shouldExposeMcpTool({
|
|
63
|
+
server,
|
|
64
|
+
toolName: 'read',
|
|
65
|
+
langChainName: 'mcp_fs_read',
|
|
66
|
+
}),
|
|
67
|
+
true,
|
|
68
|
+
)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('respects alwaysExpose: false unless promoted', () => {
|
|
72
|
+
const server = baseServer({ alwaysExpose: false })
|
|
73
|
+
assert.equal(
|
|
74
|
+
shouldExposeMcpTool({
|
|
75
|
+
server,
|
|
76
|
+
toolName: 'read',
|
|
77
|
+
langChainName: 'mcp_fs_read',
|
|
78
|
+
}),
|
|
79
|
+
false,
|
|
80
|
+
)
|
|
81
|
+
const promoter = new SessionToolPromoter()
|
|
82
|
+
promoter.promote('mcp_fs_read')
|
|
83
|
+
assert.equal(
|
|
84
|
+
shouldExposeMcpTool({
|
|
85
|
+
server,
|
|
86
|
+
toolName: 'read',
|
|
87
|
+
langChainName: 'mcp_fs_read',
|
|
88
|
+
promoter,
|
|
89
|
+
}),
|
|
90
|
+
true,
|
|
91
|
+
)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('honors string[] allowlist by bare tool name', () => {
|
|
95
|
+
const server = baseServer({ alwaysExpose: ['read'] })
|
|
96
|
+
assert.equal(
|
|
97
|
+
shouldExposeMcpTool({
|
|
98
|
+
server,
|
|
99
|
+
toolName: 'read',
|
|
100
|
+
langChainName: 'mcp_fs_read',
|
|
101
|
+
}),
|
|
102
|
+
true,
|
|
103
|
+
)
|
|
104
|
+
assert.equal(
|
|
105
|
+
shouldExposeMcpTool({
|
|
106
|
+
server,
|
|
107
|
+
toolName: 'write',
|
|
108
|
+
langChainName: 'mcp_fs_write',
|
|
109
|
+
}),
|
|
110
|
+
false,
|
|
111
|
+
)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('per-agent mcpEagerTools overrides server policy', () => {
|
|
115
|
+
const server = baseServer({ alwaysExpose: false })
|
|
116
|
+
assert.equal(
|
|
117
|
+
shouldExposeMcpTool({
|
|
118
|
+
server,
|
|
119
|
+
toolName: 'read',
|
|
120
|
+
langChainName: 'mcp_fs_read',
|
|
121
|
+
agentEagerTools: ['read'],
|
|
122
|
+
}),
|
|
123
|
+
true,
|
|
124
|
+
)
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
describe('searchDiscoveredTools', () => {
|
|
129
|
+
const discovered: DiscoveredTool[] = [
|
|
130
|
+
{
|
|
131
|
+
name: 'read_file',
|
|
132
|
+
langChainName: 'mcp_fs_read_file',
|
|
133
|
+
description: 'Read a file from disk',
|
|
134
|
+
serverId: 's1',
|
|
135
|
+
serverName: 'fs',
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
name: 'list_issues',
|
|
139
|
+
langChainName: 'mcp_github_list_issues',
|
|
140
|
+
description: 'List GitHub issues in a repository',
|
|
141
|
+
serverId: 's2',
|
|
142
|
+
serverName: 'github',
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
name: 'run_sql',
|
|
146
|
+
langChainName: 'mcp_db_run_sql',
|
|
147
|
+
description: 'Execute a SQL query against Postgres',
|
|
148
|
+
serverId: 's3',
|
|
149
|
+
serverName: 'db',
|
|
150
|
+
},
|
|
151
|
+
]
|
|
152
|
+
|
|
153
|
+
beforeEach(() => {
|
|
154
|
+
for (const t of discovered) {
|
|
155
|
+
recordDiscoveredTools(t.serverId, [t])
|
|
156
|
+
}
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('matches on bare tool name substring', () => {
|
|
160
|
+
const matches = searchDiscoveredTools('read_file')
|
|
161
|
+
assert.equal(matches[0]?.name, 'mcp_fs_read_file')
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('matches on description keywords', () => {
|
|
165
|
+
const matches = searchDiscoveredTools('github issues')
|
|
166
|
+
assert.equal(matches[0]?.name, 'mcp_github_list_issues')
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('returns empty for empty query', () => {
|
|
170
|
+
assert.deepEqual(searchDiscoveredTools(''), [])
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('honors limit', () => {
|
|
174
|
+
const matches = searchDiscoveredTools('read list run', 2)
|
|
175
|
+
assert.ok(matches.length <= 2)
|
|
176
|
+
})
|
|
177
|
+
})
|