@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,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
+ })