@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,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,98 @@
|
|
|
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
|
+
isPooled,
|
|
10
|
+
poolSize,
|
|
11
|
+
} from './mcp-connection-pool'
|
|
12
|
+
|
|
13
|
+
let connectCalls = 0
|
|
14
|
+
let disconnectCalls = 0
|
|
15
|
+
|
|
16
|
+
function server(id: string, overrides: Partial<McpServerConfig> = {}): McpServerConfig {
|
|
17
|
+
return {
|
|
18
|
+
id,
|
|
19
|
+
name: `srv-${id}`,
|
|
20
|
+
transport: 'stdio',
|
|
21
|
+
command: 'echo',
|
|
22
|
+
args: [],
|
|
23
|
+
createdAt: 0,
|
|
24
|
+
updatedAt: 0,
|
|
25
|
+
...overrides,
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('mcp-connection-pool', () => {
|
|
30
|
+
beforeEach(async () => {
|
|
31
|
+
await evictAllMcpClients()
|
|
32
|
+
connectCalls = 0
|
|
33
|
+
disconnectCalls = 0
|
|
34
|
+
__setPoolConnector({
|
|
35
|
+
connect: async () => {
|
|
36
|
+
connectCalls += 1
|
|
37
|
+
return {
|
|
38
|
+
client: { seq: connectCalls } as never,
|
|
39
|
+
transport: { seq: connectCalls } as never,
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
disconnect: async () => {
|
|
43
|
+
disconnectCalls += 1
|
|
44
|
+
},
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
after(() => {
|
|
49
|
+
__setPoolConnector()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('connects once per server and reuses on subsequent calls', async () => {
|
|
53
|
+
const a = await getOrConnectMcpClient(server('a'))
|
|
54
|
+
const b = await getOrConnectMcpClient(server('a'))
|
|
55
|
+
assert.equal(connectCalls, 1)
|
|
56
|
+
assert.equal(a.client, b.client)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('reconnects when the config fingerprint changes', async () => {
|
|
60
|
+
await getOrConnectMcpClient(server('a', { args: ['--v1'] }))
|
|
61
|
+
await getOrConnectMcpClient(server('a', { args: ['--v2'] }))
|
|
62
|
+
assert.equal(connectCalls, 2)
|
|
63
|
+
assert.equal(disconnectCalls, 1)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('evictMcpClient disconnects and lets the next call reconnect', async () => {
|
|
67
|
+
await getOrConnectMcpClient(server('a'))
|
|
68
|
+
await evictMcpClient('a')
|
|
69
|
+
assert.equal(disconnectCalls, 1)
|
|
70
|
+
assert.equal(isPooled('a'), false)
|
|
71
|
+
await getOrConnectMcpClient(server('a'))
|
|
72
|
+
assert.equal(connectCalls, 2)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('coalesces concurrent connects', async () => {
|
|
76
|
+
const [a, b] = await Promise.all([
|
|
77
|
+
getOrConnectMcpClient(server('dup')),
|
|
78
|
+
getOrConnectMcpClient(server('dup')),
|
|
79
|
+
])
|
|
80
|
+
assert.equal(connectCalls, 1)
|
|
81
|
+
assert.equal(a.client, b.client)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('tracks multiple distinct servers independently', async () => {
|
|
85
|
+
await getOrConnectMcpClient(server('a'))
|
|
86
|
+
await getOrConnectMcpClient(server('b'))
|
|
87
|
+
assert.equal(poolSize(), 2)
|
|
88
|
+
assert.equal(connectCalls, 2)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('evictAll clears everything', async () => {
|
|
92
|
+
await getOrConnectMcpClient(server('a'))
|
|
93
|
+
await getOrConnectMcpClient(server('b'))
|
|
94
|
+
await evictAllMcpClients()
|
|
95
|
+
assert.equal(disconnectCalls, 2)
|
|
96
|
+
assert.equal(poolSize(), 0)
|
|
97
|
+
})
|
|
98
|
+
})
|
|
@@ -0,0 +1,134 @@
|
|
|
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
|
+
}
|
|
@@ -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
|
+
})
|