@blocks-network/mcp-server 0.1.64 → 0.1.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 CHANGED
@@ -118,6 +118,7 @@ Add to your `~/.gemini/settings.json`:
118
118
  | `resume_task` | Resume a paused pipe task |
119
119
  | `retry_task` | Retry a failed task |
120
120
  | `list_agents` | List available agents in the registry |
121
+ | `search_agent` | Search the registry for agents by free-text query (name, description, tags, provider, category) |
121
122
  | `get_agent_card` | Get the full agent card for a specific agent |
122
123
  | `get_agent_status` | Check live availability for agents (online instance count and total task count). Per-instance live activity counters (`activeTasks`, `concurrentTasksPerInstance`, `startedAt`, `totalActiveTasks`) are reserved in the response shape but currently return `0` — the backend does not yet populate them. |
123
124
  | `connect_task` | Connect to an existing task and stream events |
package/dist/index.js CHANGED
@@ -5,10 +5,10 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
5
5
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
6
6
  import { z } from 'zod';
7
7
  import { TaskClient, textPart, filePartFromPath, fetchCdmConfig, getAgent, BLOCKS_MAX_UPLOAD_BYTES, } from '@blocks-network/sdk';
8
- import { listAgentsAuthenticated } from './registry-list.js';
8
+ import { listAllAgentsAuthenticated } from './registry-list.js';
9
9
  import { fetchAgentStatus, MAX_AGENT_NAMES } from './agent-status.js';
10
10
  import { getConsumerBalance, createConsumerTopUp } from './billing.js';
11
- import { sendTask, getTask, listTasks, cancelTask, pauseTask, resumeTask, retryTask, listAgents, getAgentCard, getAgentStatus, connectTask, downloadArtifact, checkBalance, requestTopup, defaultFileSize, } from './tools.js';
11
+ import { sendTask, getTask, listTasks, cancelTask, pauseTask, resumeTask, retryTask, listAgents, searchAgents, getAgentCard, getAgentStatus, connectTask, downloadArtifact, checkBalance, requestTopup, defaultFileSize, } from './tools.js';
12
12
  const ALLOWED_ROOT = process.env.BLOCKS_MCP_FILE_ROOT ?? process.cwd();
13
13
  if (!process.env.BLOCKS_API_KEY) {
14
14
  console.error('Warning: BLOCKS_API_KEY is not set. Private agents and paid tasks will not work.');
@@ -70,7 +70,7 @@ const deps = {
70
70
  getOrgId: () => process.env.BLOCKS_ORG_ID,
71
71
  getTaskClient,
72
72
  getAgentByName: (agentName, options) => getAgent(agentName, options),
73
- listAgents: listAgentsAuthenticated,
73
+ listAgents: listAllAgentsAuthenticated,
74
74
  fetchAgentStatus,
75
75
  getConsumerBalance,
76
76
  createConsumerTopUp,
@@ -111,14 +111,37 @@ server.tool('cancel_task', 'Cancel a running task', { taskId: z.string().describ
111
111
  server.tool('pause_task', 'Pause a running pipe task. Resume later with resume_task.', { taskId: z.string().describe('The task ID to pause') }, (params) => pauseTask(params, deps));
112
112
  server.tool('resume_task', 'Resume a paused pipe task', { taskId: z.string().describe('The task ID to resume') }, (params) => resumeTask(params, deps));
113
113
  server.tool('retry_task', 'Retry a failed task', { taskId: z.string().describe('The task ID to retry') }, (params) => retryTask(params, deps));
114
- server.tool('list_agents', 'List available agents in the Blocks Network registry. Use listing="private" with an API key to discover your private agents.', {
114
+ server.tool('list_agents', 'List available agents in the Blocks Network registry. By default only agents with at least one online instance are returned; set includeOffline=true to also list registered agents that are currently offline. Use listing="private" with an API key to discover your private agents.', {
115
115
  tag: z.string().optional().describe('Filter by tag slug'),
116
116
  listing: z
117
117
  .enum(['public', 'private'])
118
118
  .optional()
119
119
  .describe('Filter by listing visibility (default: public)'),
120
- limit: z.number().optional().describe('Max results to return'),
120
+ limit: z
121
+ .number()
122
+ .optional()
123
+ .describe('Max agents to fetch across all pages; omit to fetch all'),
124
+ includeOffline: z
125
+ .boolean()
126
+ .optional()
127
+ .describe('Include agents with no online instances (default: false)'),
121
128
  }, (params) => listAgents(params, deps));
129
+ server.tool('search_agent', 'Search the Blocks Network registry for agents matching a free-text query. The query matches against agent name, display name, description, tags, provider, and category, and supports field qualifiers (e.g. "agentname:translate", tag:"data"), quoted phrases, and negation ("-deprecated"). By default only agents with at least one online instance are returned; set includeOffline=true to also include matching agents that are currently offline. Use listing="private" with an API key to search your private agents.', {
130
+ query: z.string().describe('Free-text search query'),
131
+ tag: z.string().optional().describe('Additionally filter by tag slug'),
132
+ listing: z
133
+ .enum(['public', 'private'])
134
+ .optional()
135
+ .describe('Filter by listing visibility (default: public)'),
136
+ limit: z
137
+ .number()
138
+ .optional()
139
+ .describe('Max matching agents to fetch across all pages; omit to fetch all'),
140
+ includeOffline: z
141
+ .boolean()
142
+ .optional()
143
+ .describe('Include agents with no online instances (default: false)'),
144
+ }, (params) => searchAgents(params, deps));
122
145
  server.tool('get_agent_card', 'Get the full agent card for a specific agent', { agentName: z.string().describe('Agent name to look up') }, (params) => getAgentCard(params, deps));
123
146
  server.tool('get_agent_status', 'Check live availability for one or more agents: how many instances are online, total task count for the agent, and SDK/CLI versions per instance. (Per-instance live activity counters such as `activeTasks`, `concurrentTasksPerInstance`, `startedAt`, and `totalActiveTasks` are reserved in the response shape but not yet populated by the backend — they currently return 0.)', {
124
147
  agentNames: z
@@ -19,13 +19,35 @@ export interface AgentListEntry {
19
19
  export interface ListAgentsOptions {
20
20
  baseUrl: string;
21
21
  apiKey?: string;
22
+ /** Free-text search query (`q`); matches agent name, description, tags, etc. */
23
+ q?: string;
22
24
  tag?: string;
23
25
  listing?: 'public' | 'private';
26
+ /** Page size for a single request. The backend caps this at 100. */
24
27
  limit?: number;
28
+ /** Opaque pagination cursor returned as `next` by a previous request. */
29
+ cursor?: string;
25
30
  fetchImpl?: typeof fetch;
26
31
  }
27
32
  export interface ListAgentsResult {
28
33
  agents: AgentListEntry[];
29
34
  totalCount?: number;
35
+ /** Opaque cursor for the next page, or null/absent when there are no more. */
36
+ next?: string | null;
37
+ /** Total agents with at least one online instance (ignores cursor). */
38
+ totalOnlineCount?: number;
30
39
  }
40
+ /** Backend maximum page size for GET /api/v1/registry/agents. */
41
+ export declare const REGISTRY_PAGE_SIZE = 100;
31
42
  export declare function listAgentsAuthenticated(opts: ListAgentsOptions): Promise<ListAgentsResult>;
43
+ /**
44
+ * Fetch every agent by following the `next` cursor until it's null/absent.
45
+ *
46
+ * Uses the backend's maximum page size (100) to minimise round-trips. The
47
+ * optional `maxAgents` cap bounds the total number of agents collected and
48
+ * shrinks the final page request so we never over-fetch. The latest
49
+ * `totalCount`/`totalOnlineCount` seen are carried forward to the result.
50
+ */
51
+ export declare function listAllAgentsAuthenticated(opts: ListAgentsOptions & {
52
+ maxAgents?: number;
53
+ }): Promise<ListAgentsResult>;
@@ -6,8 +6,14 @@
6
6
  * optional Bearer API key for private/owned listings.
7
7
  */
8
8
  import { PROTOCOL_VERSION_HEADER, CURRENT_PROTOCOL_VERSION, } from './protocol-headers.js';
9
+ /** Backend maximum page size for GET /api/v1/registry/agents. */
10
+ export const REGISTRY_PAGE_SIZE = 100;
11
+ /** Safety backstop so a misbehaving (never-null) cursor can't loop forever. */
12
+ const MAX_PAGES = 1000;
9
13
  export async function listAgentsAuthenticated(opts) {
10
14
  const params = new URLSearchParams({ include: 'full' });
15
+ if (opts.q)
16
+ params.set('q', opts.q);
11
17
  if (opts.tag)
12
18
  params.set('tag', opts.tag);
13
19
  if (opts.listing) {
@@ -17,6 +23,8 @@ export async function listAgentsAuthenticated(opts) {
17
23
  }
18
24
  if (opts.limit)
19
25
  params.set('limit', String(opts.limit));
26
+ if (opts.cursor)
27
+ params.set('cursor', opts.cursor);
20
28
  const url = `${opts.baseUrl.replace(/\/+$/, '')}/api/v1/registry/agents?${params}`;
21
29
  const headers = {
22
30
  [PROTOCOL_VERSION_HEADER]: CURRENT_PROTOCOL_VERSION,
@@ -30,3 +38,42 @@ export async function listAgentsAuthenticated(opts) {
30
38
  }
31
39
  return (await response.json());
32
40
  }
41
+ /**
42
+ * Fetch every agent by following the `next` cursor until it's null/absent.
43
+ *
44
+ * Uses the backend's maximum page size (100) to minimise round-trips. The
45
+ * optional `maxAgents` cap bounds the total number of agents collected and
46
+ * shrinks the final page request so we never over-fetch. The latest
47
+ * `totalCount`/`totalOnlineCount` seen are carried forward to the result.
48
+ */
49
+ export async function listAllAgentsAuthenticated(opts) {
50
+ const agents = [];
51
+ let cursor = opts.cursor;
52
+ let totalCount;
53
+ let totalOnlineCount;
54
+ const cap = opts.maxAgents;
55
+ for (let page = 0; page < MAX_PAGES; page++) {
56
+ const remaining = cap !== undefined ? cap - agents.length : undefined;
57
+ if (remaining !== undefined && remaining <= 0)
58
+ break;
59
+ const pageSize = remaining !== undefined
60
+ ? Math.min(REGISTRY_PAGE_SIZE, remaining)
61
+ : REGISTRY_PAGE_SIZE;
62
+ const result = await listAgentsAuthenticated({
63
+ ...opts,
64
+ limit: pageSize,
65
+ cursor,
66
+ });
67
+ agents.push(...result.agents);
68
+ if (result.totalCount !== undefined)
69
+ totalCount = result.totalCount;
70
+ if (result.totalOnlineCount !== undefined) {
71
+ totalOnlineCount = result.totalOnlineCount;
72
+ }
73
+ cursor = result.next ?? undefined;
74
+ if (!cursor)
75
+ break;
76
+ }
77
+ const trimmed = cap !== undefined ? agents.slice(0, cap) : agents;
78
+ return { agents: trimmed, totalCount, totalOnlineCount, next: null };
79
+ }
package/dist/tools.d.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import { textPart, filePartFromPath, type TaskInfo, type SendMessageRequestPart } from '@blocks-network/sdk';
9
9
  import type { ListAgentsResult } from './registry-list.js';
10
- import type { AgentStatusResponse } from './agent-status.js';
10
+ import { type AgentStatusResponse } from './agent-status.js';
11
11
  import type { ConsumerBalance, TopUpSession } from './billing.js';
12
12
  export interface ArtifactRef {
13
13
  fileName?: string;
@@ -105,9 +105,12 @@ export interface ToolDeps {
105
105
  listAgents(options: {
106
106
  baseUrl: string;
107
107
  apiKey?: string;
108
+ /** Free-text search query (`q`); matches agent name, description, tags, etc. */
109
+ q?: string;
108
110
  tag?: string;
109
111
  listing?: 'public' | 'private';
110
- limit?: number;
112
+ /** Optional cap on the total number of agents to fetch across all pages. */
113
+ maxAgents?: number;
111
114
  }): Promise<ListAgentsResult>;
112
115
  validateFilePath(filePath: string): string;
113
116
  resolveSavePath(filePath: string): string;
@@ -158,6 +161,25 @@ export interface ListAgentsParams {
158
161
  tag?: string;
159
162
  listing?: 'public' | 'private';
160
163
  limit?: number;
164
+ /**
165
+ * Include agents that have no online instances. Defaults to false: the
166
+ * registry returns every registered agent regardless of whether any
167
+ * instance is currently running, so by default we filter to agents with
168
+ * at least one online instance to avoid surfacing unreachable agents.
169
+ */
170
+ includeOffline?: boolean;
171
+ }
172
+ export interface SearchAgentsParams {
173
+ /** Free-text search query; matches agent name, description, tags, etc. */
174
+ query: string;
175
+ tag?: string;
176
+ listing?: 'public' | 'private';
177
+ limit?: number;
178
+ /**
179
+ * Include agents that have no online instances. Defaults to false so we
180
+ * only surface agents that can actually take a task. See ListAgentsParams.
181
+ */
182
+ includeOffline?: boolean;
161
183
  }
162
184
  export interface GetAgentCardParams {
163
185
  agentName: string;
@@ -185,6 +207,7 @@ export declare function getTask(params: GetTaskParams, deps: ToolDeps): Promise<
185
207
  export declare function listTasks(params: ListTasksParams, deps: ToolDeps): Promise<ToolResult>;
186
208
  export declare function cancelTask(params: CancelTaskParams, deps: ToolDeps): Promise<ToolResult>;
187
209
  export declare function listAgents(params: ListAgentsParams, deps: ToolDeps): Promise<ToolResult>;
210
+ export declare function searchAgents(params: SearchAgentsParams, deps: ToolDeps): Promise<ToolResult>;
188
211
  export declare function getAgentCard(params: GetAgentCardParams, deps: ToolDeps): Promise<ToolResult>;
189
212
  export declare function connectTask(params: ConnectTaskParams, deps: ToolDeps): Promise<ToolResult>;
190
213
  export declare function downloadArtifact(params: DownloadArtifactParams, deps: ToolDeps): Promise<ToolResult>;
package/dist/tools.js CHANGED
@@ -6,6 +6,7 @@
6
6
  * touching the network or the filesystem.
7
7
  */
8
8
  import { statSync } from 'node:fs';
9
+ import { MAX_AGENT_NAMES, AGENT_NAME_PATTERN, } from './agent-status.js';
9
10
  // ============================================================================
10
11
  // Helpers
11
12
  // ============================================================================
@@ -163,15 +164,87 @@ export async function listAgents(params, deps) {
163
164
  apiKey,
164
165
  tag: params.tag,
165
166
  listing: params.listing,
166
- limit: params.limit,
167
+ maxAgents: params.limit,
167
168
  });
168
- const lines = result.agents.map((a) => {
169
- const tags = a.tags?.map((t) => t.name).join(', ') ?? '';
170
- return `${a.agentName} | ${a.name ?? a.agentName} | ${a.listing ?? 'public'} | ${tags}`;
169
+ const total = result.totalCount ?? result.agents.length;
170
+ // The registry returns every registered agent regardless of whether any
171
+ // instance is currently running. Default to online-only so we never
172
+ // surface agents that can't actually take a task; `includeOffline` opts
173
+ // back into the full registry view.
174
+ if (params.includeOffline) {
175
+ const lines = result.agents.map(formatAgentRow);
176
+ const header = `Agents (${result.agents.length} of ${total} total):`;
177
+ return { content: [{ type: 'text', text: [header, ...lines].join('\n') }] };
178
+ }
179
+ const online = await filterOnlineAgents(result.agents, { baseUrl, apiKey }, deps);
180
+ const lines = online.map(formatAgentRow);
181
+ const header = `Agents (${online.length} online of ${total} total):`;
182
+ return { content: [{ type: 'text', text: [header, ...lines].join('\n') }] };
183
+ }
184
+ // ============================================================================
185
+ // search_agent
186
+ // ============================================================================
187
+ export async function searchAgents(params, deps) {
188
+ const query = params.query.trim();
189
+ if (!query) {
190
+ return {
191
+ content: [{ type: 'text', text: 'Search query must not be empty.' }],
192
+ isError: true,
193
+ };
194
+ }
195
+ const baseUrl = await deps.getBaseUrl();
196
+ const apiKey = deps.getApiKey();
197
+ const result = await deps.listAgents({
198
+ baseUrl,
199
+ apiKey,
200
+ q: query,
201
+ tag: params.tag,
202
+ listing: params.listing,
203
+ maxAgents: params.limit,
171
204
  });
172
- const header = `Agents (${result.totalCount ?? result.agents.length}):`;
205
+ const total = result.totalCount ?? result.agents.length;
206
+ // Mirror list_agents: default to online-only so we never surface agents
207
+ // that can't actually take a task; `includeOffline` opts back into the
208
+ // full set of matches.
209
+ if (params.includeOffline) {
210
+ const lines = result.agents.map(formatAgentRow);
211
+ const header = `Agents matching "${query}" (${result.agents.length} of ${total} total):`;
212
+ return { content: [{ type: 'text', text: [header, ...lines].join('\n') }] };
213
+ }
214
+ const online = await filterOnlineAgents(result.agents, { baseUrl, apiKey }, deps);
215
+ const lines = online.map(formatAgentRow);
216
+ const header = `Agents matching "${query}" (${online.length} online of ${total} total):`;
173
217
  return { content: [{ type: 'text', text: [header, ...lines].join('\n') }] };
174
218
  }
219
+ function formatAgentRow(a) {
220
+ const tags = a.tags?.map((t) => t.name).join(', ') ?? '';
221
+ return `${a.agentName} | ${a.name ?? a.agentName} | ${a.listing ?? 'public'} | ${tags}`;
222
+ }
223
+ /**
224
+ * Keep only agents that have at least one online instance, as reported by
225
+ * the agent-status service. Agent names that the status endpoint can't
226
+ * accept (too many, or invalid characters) are queried in valid batches;
227
+ * any name that's unqueryable is treated as offline and dropped.
228
+ */
229
+ async function filterOnlineAgents(agents, ctx, deps) {
230
+ const queryable = agents.filter((a) => AGENT_NAME_PATTERN.test(a.agentName));
231
+ if (queryable.length === 0)
232
+ return [];
233
+ const online = new Set();
234
+ for (let i = 0; i < queryable.length; i += MAX_AGENT_NAMES) {
235
+ const batch = queryable.slice(i, i + MAX_AGENT_NAMES);
236
+ const status = await deps.fetchAgentStatus({
237
+ baseUrl: ctx.baseUrl,
238
+ apiKey: ctx.apiKey,
239
+ agentNames: batch.map((a) => a.agentName),
240
+ });
241
+ for (const [name, info] of Object.entries(status.agents)) {
242
+ if ((info.onlineCount ?? 0) > 0)
243
+ online.add(name);
244
+ }
245
+ }
246
+ return agents.filter((a) => online.has(a.agentName));
247
+ }
175
248
  // ============================================================================
176
249
  // get_agent_card
177
250
  // ============================================================================
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blocks-network/mcp-server",
3
- "version": "0.1.64",
3
+ "version": "0.1.65",
4
4
  "description": "MCP server for Blocks Network consumer operations",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/src/index.ts CHANGED
@@ -14,7 +14,7 @@ import {
14
14
  BLOCKS_MAX_UPLOAD_BYTES,
15
15
  } from '@blocks-network/sdk';
16
16
 
17
- import { listAgentsAuthenticated } from './registry-list.js';
17
+ import { listAllAgentsAuthenticated } from './registry-list.js';
18
18
  import { fetchAgentStatus, MAX_AGENT_NAMES } from './agent-status.js';
19
19
  import { getConsumerBalance, createConsumerTopUp } from './billing.js';
20
20
  import {
@@ -26,6 +26,7 @@ import {
26
26
  resumeTask,
27
27
  retryTask,
28
28
  listAgents,
29
+ searchAgents,
29
30
  getAgentCard,
30
31
  getAgentStatus,
31
32
  connectTask,
@@ -106,7 +107,7 @@ const deps: ToolDeps = {
106
107
  getTaskClient,
107
108
  getAgentByName: (agentName, options) =>
108
109
  getAgent(agentName, options) as Promise<AgentEntryLike | null>,
109
- listAgents: listAgentsAuthenticated,
110
+ listAgents: listAllAgentsAuthenticated,
110
111
  fetchAgentStatus,
111
112
  getConsumerBalance,
112
113
  createConsumerTopUp,
@@ -193,18 +194,47 @@ server.tool(
193
194
 
194
195
  server.tool(
195
196
  'list_agents',
196
- 'List available agents in the Blocks Network registry. Use listing="private" with an API key to discover your private agents.',
197
+ 'List available agents in the Blocks Network registry. By default only agents with at least one online instance are returned; set includeOffline=true to also list registered agents that are currently offline. Use listing="private" with an API key to discover your private agents.',
197
198
  {
198
199
  tag: z.string().optional().describe('Filter by tag slug'),
199
200
  listing: z
200
201
  .enum(['public', 'private'])
201
202
  .optional()
202
203
  .describe('Filter by listing visibility (default: public)'),
203
- limit: z.number().optional().describe('Max results to return'),
204
+ limit: z
205
+ .number()
206
+ .optional()
207
+ .describe('Max agents to fetch across all pages; omit to fetch all'),
208
+ includeOffline: z
209
+ .boolean()
210
+ .optional()
211
+ .describe('Include agents with no online instances (default: false)'),
204
212
  },
205
213
  (params) => listAgents(params, deps),
206
214
  );
207
215
 
216
+ server.tool(
217
+ 'search_agent',
218
+ 'Search the Blocks Network registry for agents matching a free-text query. The query matches against agent name, display name, description, tags, provider, and category, and supports field qualifiers (e.g. "agentname:translate", tag:"data"), quoted phrases, and negation ("-deprecated"). By default only agents with at least one online instance are returned; set includeOffline=true to also include matching agents that are currently offline. Use listing="private" with an API key to search your private agents.',
219
+ {
220
+ query: z.string().describe('Free-text search query'),
221
+ tag: z.string().optional().describe('Additionally filter by tag slug'),
222
+ listing: z
223
+ .enum(['public', 'private'])
224
+ .optional()
225
+ .describe('Filter by listing visibility (default: public)'),
226
+ limit: z
227
+ .number()
228
+ .optional()
229
+ .describe('Max matching agents to fetch across all pages; omit to fetch all'),
230
+ includeOffline: z
231
+ .boolean()
232
+ .optional()
233
+ .describe('Include agents with no online instances (default: false)'),
234
+ },
235
+ (params) => searchAgents(params, deps),
236
+ );
237
+
208
238
  server.tool(
209
239
  'get_agent_card',
210
240
  'Get the full agent card for a specific agent',
@@ -23,27 +23,44 @@ export interface AgentListEntry {
23
23
  export interface ListAgentsOptions {
24
24
  baseUrl: string;
25
25
  apiKey?: string;
26
+ /** Free-text search query (`q`); matches agent name, description, tags, etc. */
27
+ q?: string;
26
28
  tag?: string;
27
29
  listing?: 'public' | 'private';
30
+ /** Page size for a single request. The backend caps this at 100. */
28
31
  limit?: number;
32
+ /** Opaque pagination cursor returned as `next` by a previous request. */
33
+ cursor?: string;
29
34
  fetchImpl?: typeof fetch;
30
35
  }
31
36
 
32
37
  export interface ListAgentsResult {
33
38
  agents: AgentListEntry[];
34
39
  totalCount?: number;
40
+ /** Opaque cursor for the next page, or null/absent when there are no more. */
41
+ next?: string | null;
42
+ /** Total agents with at least one online instance (ignores cursor). */
43
+ totalOnlineCount?: number;
35
44
  }
36
45
 
46
+ /** Backend maximum page size for GET /api/v1/registry/agents. */
47
+ export const REGISTRY_PAGE_SIZE = 100;
48
+
49
+ /** Safety backstop so a misbehaving (never-null) cursor can't loop forever. */
50
+ const MAX_PAGES = 1000;
51
+
37
52
  export async function listAgentsAuthenticated(
38
53
  opts: ListAgentsOptions,
39
54
  ): Promise<ListAgentsResult> {
40
55
  const params = new URLSearchParams({ include: 'full' });
56
+ if (opts.q) params.set('q', opts.q);
41
57
  if (opts.tag) params.set('tag', opts.tag);
42
58
  if (opts.listing) {
43
59
  params.set('listing', opts.listing);
44
60
  if (opts.listing === 'private') params.set('scope', 'owned');
45
61
  }
46
62
  if (opts.limit) params.set('limit', String(opts.limit));
63
+ if (opts.cursor) params.set('cursor', opts.cursor);
47
64
 
48
65
  const url = `${opts.baseUrl.replace(/\/+$/, '')}/api/v1/registry/agents?${params}`;
49
66
  const headers: Record<string, string> = {
@@ -58,3 +75,47 @@ export async function listAgentsAuthenticated(
58
75
  }
59
76
  return (await response.json()) as ListAgentsResult;
60
77
  }
78
+
79
+ /**
80
+ * Fetch every agent by following the `next` cursor until it's null/absent.
81
+ *
82
+ * Uses the backend's maximum page size (100) to minimise round-trips. The
83
+ * optional `maxAgents` cap bounds the total number of agents collected and
84
+ * shrinks the final page request so we never over-fetch. The latest
85
+ * `totalCount`/`totalOnlineCount` seen are carried forward to the result.
86
+ */
87
+ export async function listAllAgentsAuthenticated(
88
+ opts: ListAgentsOptions & { maxAgents?: number },
89
+ ): Promise<ListAgentsResult> {
90
+ const agents: AgentListEntry[] = [];
91
+ let cursor = opts.cursor;
92
+ let totalCount: number | undefined;
93
+ let totalOnlineCount: number | undefined;
94
+ const cap = opts.maxAgents;
95
+
96
+ for (let page = 0; page < MAX_PAGES; page++) {
97
+ const remaining = cap !== undefined ? cap - agents.length : undefined;
98
+ if (remaining !== undefined && remaining <= 0) break;
99
+ const pageSize =
100
+ remaining !== undefined
101
+ ? Math.min(REGISTRY_PAGE_SIZE, remaining)
102
+ : REGISTRY_PAGE_SIZE;
103
+
104
+ const result = await listAgentsAuthenticated({
105
+ ...opts,
106
+ limit: pageSize,
107
+ cursor,
108
+ });
109
+ agents.push(...result.agents);
110
+ if (result.totalCount !== undefined) totalCount = result.totalCount;
111
+ if (result.totalOnlineCount !== undefined) {
112
+ totalOnlineCount = result.totalOnlineCount;
113
+ }
114
+
115
+ cursor = result.next ?? undefined;
116
+ if (!cursor) break;
117
+ }
118
+
119
+ const trimmed = cap !== undefined ? agents.slice(0, cap) : agents;
120
+ return { agents: trimmed, totalCount, totalOnlineCount, next: null };
121
+ }
package/src/tools.ts CHANGED
@@ -15,7 +15,11 @@ import {
15
15
  } from '@blocks-network/sdk';
16
16
 
17
17
  import type { ListAgentsResult } from './registry-list.js';
18
- import type { AgentStatusResponse } from './agent-status.js';
18
+ import {
19
+ MAX_AGENT_NAMES,
20
+ AGENT_NAME_PATTERN,
21
+ type AgentStatusResponse,
22
+ } from './agent-status.js';
19
23
  import type { ConsumerBalance, TopUpSession } from './billing.js';
20
24
 
21
25
  // ============================================================================
@@ -120,9 +124,12 @@ export interface ToolDeps {
120
124
  listAgents(options: {
121
125
  baseUrl: string;
122
126
  apiKey?: string;
127
+ /** Free-text search query (`q`); matches agent name, description, tags, etc. */
128
+ q?: string;
123
129
  tag?: string;
124
130
  listing?: 'public' | 'private';
125
- limit?: number;
131
+ /** Optional cap on the total number of agents to fetch across all pages. */
132
+ maxAgents?: number;
126
133
  }): Promise<ListAgentsResult>;
127
134
  validateFilePath(filePath: string): string;
128
135
  resolveSavePath(filePath: string): string;
@@ -223,6 +230,26 @@ export interface ListAgentsParams {
223
230
  tag?: string;
224
231
  listing?: 'public' | 'private';
225
232
  limit?: number;
233
+ /**
234
+ * Include agents that have no online instances. Defaults to false: the
235
+ * registry returns every registered agent regardless of whether any
236
+ * instance is currently running, so by default we filter to agents with
237
+ * at least one online instance to avoid surfacing unreachable agents.
238
+ */
239
+ includeOffline?: boolean;
240
+ }
241
+
242
+ export interface SearchAgentsParams {
243
+ /** Free-text search query; matches agent name, description, tags, etc. */
244
+ query: string;
245
+ tag?: string;
246
+ listing?: 'public' | 'private';
247
+ limit?: number;
248
+ /**
249
+ * Include agents that have no online instances. Defaults to false so we
250
+ * only surface agents that can actually take a task. See ListAgentsParams.
251
+ */
252
+ includeOffline?: boolean;
226
253
  }
227
254
 
228
255
  export interface GetAgentCardParams {
@@ -413,17 +440,106 @@ export async function listAgents(
413
440
  apiKey,
414
441
  tag: params.tag,
415
442
  listing: params.listing,
416
- limit: params.limit,
443
+ maxAgents: params.limit,
417
444
  });
418
445
 
419
- const lines = result.agents.map((a) => {
420
- const tags = a.tags?.map((t) => t.name).join(', ') ?? '';
421
- return `${a.agentName} | ${a.name ?? a.agentName} | ${a.listing ?? 'public'} | ${tags}`;
446
+ const total = result.totalCount ?? result.agents.length;
447
+
448
+ // The registry returns every registered agent regardless of whether any
449
+ // instance is currently running. Default to online-only so we never
450
+ // surface agents that can't actually take a task; `includeOffline` opts
451
+ // back into the full registry view.
452
+ if (params.includeOffline) {
453
+ const lines = result.agents.map(formatAgentRow);
454
+ const header = `Agents (${result.agents.length} of ${total} total):`;
455
+ return { content: [{ type: 'text', text: [header, ...lines].join('\n') }] };
456
+ }
457
+
458
+ const online = await filterOnlineAgents(result.agents, { baseUrl, apiKey }, deps);
459
+ const lines = online.map(formatAgentRow);
460
+ const header = `Agents (${online.length} online of ${total} total):`;
461
+ return { content: [{ type: 'text', text: [header, ...lines].join('\n') }] };
462
+ }
463
+
464
+ // ============================================================================
465
+ // search_agent
466
+ // ============================================================================
467
+
468
+ export async function searchAgents(
469
+ params: SearchAgentsParams,
470
+ deps: ToolDeps,
471
+ ): Promise<ToolResult> {
472
+ const query = params.query.trim();
473
+ if (!query) {
474
+ return {
475
+ content: [{ type: 'text', text: 'Search query must not be empty.' }],
476
+ isError: true,
477
+ };
478
+ }
479
+
480
+ const baseUrl = await deps.getBaseUrl();
481
+ const apiKey = deps.getApiKey();
482
+ const result = await deps.listAgents({
483
+ baseUrl,
484
+ apiKey,
485
+ q: query,
486
+ tag: params.tag,
487
+ listing: params.listing,
488
+ maxAgents: params.limit,
422
489
  });
423
- const header = `Agents (${result.totalCount ?? result.agents.length}):`;
490
+
491
+ const total = result.totalCount ?? result.agents.length;
492
+
493
+ // Mirror list_agents: default to online-only so we never surface agents
494
+ // that can't actually take a task; `includeOffline` opts back into the
495
+ // full set of matches.
496
+ if (params.includeOffline) {
497
+ const lines = result.agents.map(formatAgentRow);
498
+ const header = `Agents matching "${query}" (${result.agents.length} of ${total} total):`;
499
+ return { content: [{ type: 'text', text: [header, ...lines].join('\n') }] };
500
+ }
501
+
502
+ const online = await filterOnlineAgents(result.agents, { baseUrl, apiKey }, deps);
503
+ const lines = online.map(formatAgentRow);
504
+ const header = `Agents matching "${query}" (${online.length} online of ${total} total):`;
424
505
  return { content: [{ type: 'text', text: [header, ...lines].join('\n') }] };
425
506
  }
426
507
 
508
+ function formatAgentRow(a: ListAgentsResult['agents'][number]): string {
509
+ const tags = a.tags?.map((t) => t.name).join(', ') ?? '';
510
+ return `${a.agentName} | ${a.name ?? a.agentName} | ${a.listing ?? 'public'} | ${tags}`;
511
+ }
512
+
513
+ /**
514
+ * Keep only agents that have at least one online instance, as reported by
515
+ * the agent-status service. Agent names that the status endpoint can't
516
+ * accept (too many, or invalid characters) are queried in valid batches;
517
+ * any name that's unqueryable is treated as offline and dropped.
518
+ */
519
+ async function filterOnlineAgents(
520
+ agents: ListAgentsResult['agents'],
521
+ ctx: { baseUrl: string; apiKey?: string },
522
+ deps: ToolDeps,
523
+ ): Promise<ListAgentsResult['agents']> {
524
+ const queryable = agents.filter((a) => AGENT_NAME_PATTERN.test(a.agentName));
525
+ if (queryable.length === 0) return [];
526
+
527
+ const online = new Set<string>();
528
+ for (let i = 0; i < queryable.length; i += MAX_AGENT_NAMES) {
529
+ const batch = queryable.slice(i, i + MAX_AGENT_NAMES);
530
+ const status = await deps.fetchAgentStatus({
531
+ baseUrl: ctx.baseUrl,
532
+ apiKey: ctx.apiKey,
533
+ agentNames: batch.map((a) => a.agentName),
534
+ });
535
+ for (const [name, info] of Object.entries(status.agents)) {
536
+ if ((info.onlineCount ?? 0) > 0) online.add(name);
537
+ }
538
+ }
539
+
540
+ return agents.filter((a) => online.has(a.agentName));
541
+ }
542
+
427
543
  // ============================================================================
428
544
  // get_agent_card
429
545
  // ============================================================================
@@ -2,6 +2,16 @@ import { describe, it, expect } from 'vitest';
2
2
  import { listAgents } from '../src/tools.js';
3
3
  import { makeFakeDeps } from './helpers.js';
4
4
 
5
+ // A status response where every named agent has one online instance, so the
6
+ // default online-only filter is a no-op for these agents.
7
+ function allOnline(...names: string[]) {
8
+ const agents: Record<string, unknown> = {};
9
+ for (const name of names) {
10
+ agents[name] = { agentName: name, instances: [], onlineCount: 1, taskCount: 0 };
11
+ }
12
+ return { agents };
13
+ }
14
+
5
15
  describe('list_agents', () => {
6
16
  it('renders one row per agent with tag names joined by commas', async () => {
7
17
  const { deps } = makeFakeDeps({
@@ -20,18 +30,19 @@ describe('list_agents', () => {
20
30
  ],
21
31
  totalCount: 2,
22
32
  },
33
+ agentStatusResult: allOnline('alice', 'bob'),
23
34
  });
24
35
 
25
36
  const res = await listAgents({}, deps);
26
37
  const lines = res.content[0].text.split('\n');
27
38
  expect(lines).toEqual([
28
- 'Agents (2):',
39
+ 'Agents (2 online of 2 total):',
29
40
  'alice | Alice | public | Translate, Summarize',
30
41
  'bob | bob | private | ',
31
42
  ]);
32
43
  });
33
44
 
34
- it('forwards baseUrl, apiKey, tag, listing, limit to the registry helper', async () => {
45
+ it('forwards baseUrl, apiKey, tag, listing, limit (as maxAgents) to the registry helper', async () => {
35
46
  const { deps, mocks } = makeFakeDeps({
36
47
  apiKey: 'sk-test',
37
48
  baseUrl: 'http://api.test',
@@ -47,7 +58,7 @@ describe('list_agents', () => {
47
58
  apiKey: 'sk-test',
48
59
  tag: 'translate',
49
60
  listing: 'private',
50
- limit: 25,
61
+ maxAgents: 25,
51
62
  });
52
63
  });
53
64
 
@@ -57,18 +68,104 @@ describe('list_agents', () => {
57
68
  agents: [{ agentName: 'naked', tags: [] }],
58
69
  totalCount: 1,
59
70
  },
71
+ agentStatusResult: allOnline('naked'),
60
72
  });
61
73
 
62
74
  const res = await listAgents({}, deps);
63
75
  expect(res.content[0].text).toContain('naked | naked | public |');
64
76
  });
65
77
 
66
- it('uses agents.length as the count when totalCount is omitted', async () => {
78
+ it('uses the online agent count for the header, falling back to length for total', async () => {
67
79
  const { deps } = makeFakeDeps({
68
80
  listAgentsResult: { agents: [{ agentName: 'a' }, { agentName: 'b' }] },
81
+ agentStatusResult: allOnline('a', 'b'),
82
+ });
83
+
84
+ const res = await listAgents({}, deps);
85
+ expect(res.content[0].text.split('\n')[0]).toBe('Agents (2 online of 2 total):');
86
+ });
87
+
88
+ it('reports the registry total separately from the online count', async () => {
89
+ const { deps } = makeFakeDeps({
90
+ listAgentsResult: {
91
+ agents: [{ agentName: 'online' }, { agentName: 'offline' }],
92
+ totalCount: 50,
93
+ },
94
+ agentStatusResult: {
95
+ agents: {
96
+ online: { agentName: 'online', instances: [], onlineCount: 1, taskCount: 0 },
97
+ offline: { agentName: 'offline', instances: [], onlineCount: 0, taskCount: 0 },
98
+ },
99
+ },
100
+ });
101
+
102
+ const res = await listAgents({}, deps);
103
+ expect(res.content[0].text.split('\n')[0]).toBe('Agents (1 online of 50 total):');
104
+ });
105
+
106
+ it('drops offline agents by default, keeping only those with online instances', async () => {
107
+ const { deps, mocks } = makeFakeDeps({
108
+ listAgentsResult: {
109
+ agents: [
110
+ { agentName: 'online', name: 'Online' },
111
+ { agentName: 'offline', name: 'Offline' },
112
+ ],
113
+ totalCount: 2,
114
+ },
115
+ agentStatusResult: {
116
+ agents: {
117
+ online: { agentName: 'online', instances: [], onlineCount: 1, taskCount: 0 },
118
+ offline: { agentName: 'offline', instances: [], onlineCount: 0, taskCount: 0 },
119
+ },
120
+ },
69
121
  });
70
122
 
71
123
  const res = await listAgents({}, deps);
72
- expect(res.content[0].text.split('\n')[0]).toBe('Agents (2):');
124
+ const lines = res.content[0].text.split('\n');
125
+ expect(lines).toEqual([
126
+ 'Agents (1 online of 2 total):',
127
+ 'online | Online | public | ',
128
+ ]);
129
+ expect(mocks.fetchAgentStatus).toHaveBeenCalledWith({
130
+ baseUrl: 'http://api.test',
131
+ apiKey: undefined,
132
+ agentNames: ['online', 'offline'],
133
+ });
134
+ });
135
+
136
+ it('includes offline agents and skips the status check when includeOffline is true', async () => {
137
+ const { deps, mocks } = makeFakeDeps({
138
+ listAgentsResult: {
139
+ agents: [
140
+ { agentName: 'online' },
141
+ { agentName: 'offline' },
142
+ ],
143
+ totalCount: 2,
144
+ },
145
+ });
146
+
147
+ const res = await listAgents({ includeOffline: true }, deps);
148
+ expect(res.content[0].text.split('\n')[0]).toBe('Agents (2 of 2 total):');
149
+ expect(mocks.fetchAgentStatus).not.toHaveBeenCalled();
150
+ });
151
+
152
+ it('drops agents whose names the status endpoint cannot query', async () => {
153
+ const { deps } = makeFakeDeps({
154
+ listAgentsResult: {
155
+ agents: [
156
+ { agentName: 'valid_name' },
157
+ { agentName: 'has-dash' },
158
+ ],
159
+ totalCount: 2,
160
+ },
161
+ agentStatusResult: allOnline('valid_name'),
162
+ });
163
+
164
+ const res = await listAgents({}, deps);
165
+ const lines = res.content[0].text.split('\n');
166
+ expect(lines).toEqual([
167
+ 'Agents (1 online of 2 total):',
168
+ 'valid_name | valid_name | public | ',
169
+ ]);
73
170
  });
74
171
  });
@@ -1,5 +1,8 @@
1
1
  import { describe, it, expect, vi } from 'vitest';
2
- import { listAgentsAuthenticated } from '../src/registry-list.js';
2
+ import {
3
+ listAgentsAuthenticated,
4
+ listAllAgentsAuthenticated,
5
+ } from '../src/registry-list.js';
3
6
  import {
4
7
  PROTOCOL_VERSION_HEADER,
5
8
  CURRENT_PROTOCOL_VERSION,
@@ -117,4 +120,125 @@ describe('listAgentsAuthenticated', () => {
117
120
  });
118
121
  expect(result).toEqual(body);
119
122
  });
123
+
124
+ it('forwards the cursor query param when provided', async () => {
125
+ const fetchImpl = vi.fn().mockResolvedValue(
126
+ mockResponse({ agents: [], totalCount: 0 }),
127
+ );
128
+
129
+ await listAgentsAuthenticated({
130
+ baseUrl: 'http://api.test',
131
+ cursor: 'abc123',
132
+ fetchImpl: fetchImpl as unknown as typeof fetch,
133
+ });
134
+
135
+ const [url] = fetchImpl.mock.calls[0];
136
+ expect(new URL(String(url)).searchParams.get('cursor')).toBe('abc123');
137
+ });
138
+ });
139
+
140
+ describe('listAllAgentsAuthenticated', () => {
141
+ function agent(name: string) {
142
+ return { agentName: name, tags: [] };
143
+ }
144
+
145
+ it('follows the next cursor across pages and aggregates results', async () => {
146
+ const fetchImpl = vi
147
+ .fn()
148
+ .mockResolvedValueOnce(
149
+ mockResponse({
150
+ agents: [agent('a'), agent('b')],
151
+ next: 'c1',
152
+ totalCount: 5,
153
+ totalOnlineCount: 3,
154
+ }),
155
+ )
156
+ .mockResolvedValueOnce(
157
+ mockResponse({ agents: [agent('c'), agent('d')], next: 'c2' }),
158
+ )
159
+ .mockResolvedValueOnce(
160
+ mockResponse({ agents: [agent('e')], next: null }),
161
+ );
162
+
163
+ const result = await listAllAgentsAuthenticated({
164
+ baseUrl: 'http://api.test',
165
+ fetchImpl: fetchImpl as unknown as typeof fetch,
166
+ });
167
+
168
+ expect(result.agents.map((a) => a.agentName)).toEqual(['a', 'b', 'c', 'd', 'e']);
169
+ expect(result.totalCount).toBe(5);
170
+ expect(result.totalOnlineCount).toBe(3);
171
+ expect(fetchImpl).toHaveBeenCalledTimes(3);
172
+
173
+ // First request carries no cursor; follow-ups carry the prior page's next.
174
+ expect(new URL(String(fetchImpl.mock.calls[0][0])).searchParams.get('cursor')).toBeNull();
175
+ expect(new URL(String(fetchImpl.mock.calls[1][0])).searchParams.get('cursor')).toBe('c1');
176
+ expect(new URL(String(fetchImpl.mock.calls[2][0])).searchParams.get('cursor')).toBe('c2');
177
+ });
178
+
179
+ it('stops after a single page when next is absent', async () => {
180
+ const fetchImpl = vi.fn().mockResolvedValue(
181
+ mockResponse({ agents: [agent('a')], totalCount: 1 }),
182
+ );
183
+
184
+ const result = await listAllAgentsAuthenticated({
185
+ baseUrl: 'http://api.test',
186
+ fetchImpl: fetchImpl as unknown as typeof fetch,
187
+ });
188
+
189
+ expect(fetchImpl).toHaveBeenCalledOnce();
190
+ expect(result.agents.map((a) => a.agentName)).toEqual(['a']);
191
+ });
192
+
193
+ it('requests the max page size (limit=100) by default', async () => {
194
+ const fetchImpl = vi.fn().mockResolvedValue(
195
+ mockResponse({ agents: [], next: null }),
196
+ );
197
+
198
+ await listAllAgentsAuthenticated({
199
+ baseUrl: 'http://api.test',
200
+ fetchImpl: fetchImpl as unknown as typeof fetch,
201
+ });
202
+
203
+ expect(new URL(String(fetchImpl.mock.calls[0][0])).searchParams.get('limit')).toBe('100');
204
+ });
205
+
206
+ it('caps the total agents and shrinks the final page request', async () => {
207
+ const fetchImpl = vi
208
+ .fn()
209
+ .mockResolvedValueOnce(
210
+ mockResponse({ agents: [agent('a'), agent('b')], next: 'c1' }),
211
+ )
212
+ .mockResolvedValueOnce(
213
+ mockResponse({ agents: [agent('c'), agent('d')], next: 'c2' }),
214
+ );
215
+
216
+ const result = await listAllAgentsAuthenticated({
217
+ baseUrl: 'http://api.test',
218
+ maxAgents: 3,
219
+ fetchImpl: fetchImpl as unknown as typeof fetch,
220
+ });
221
+
222
+ expect(result.agents.map((a) => a.agentName)).toEqual(['a', 'b', 'c']);
223
+ // Two requests only. The cap bounds each page size: first page asks for
224
+ // min(100, 3)=3; after 2 returned, the second asks for the remaining 1.
225
+ expect(fetchImpl).toHaveBeenCalledTimes(2);
226
+ expect(new URL(String(fetchImpl.mock.calls[0][0])).searchParams.get('limit')).toBe('3');
227
+ expect(new URL(String(fetchImpl.mock.calls[1][0])).searchParams.get('limit')).toBe('1');
228
+ });
229
+
230
+ it('terminates when a backend returns a stuck non-null cursor', async () => {
231
+ const fetchImpl = vi.fn().mockResolvedValue(
232
+ mockResponse({ agents: [agent('a')], next: 'always' }),
233
+ );
234
+
235
+ const result = await listAllAgentsAuthenticated({
236
+ baseUrl: 'http://api.test',
237
+ fetchImpl: fetchImpl as unknown as typeof fetch,
238
+ });
239
+
240
+ // Bounded by the internal page guard rather than looping forever.
241
+ expect(fetchImpl).toHaveBeenCalledTimes(1000);
242
+ expect(result.agents).toHaveLength(1000);
243
+ });
120
244
  });
@@ -0,0 +1,119 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { searchAgents } from '../src/tools.js';
3
+ import { makeFakeDeps } from './helpers.js';
4
+
5
+ // A status response where every named agent has one online instance, so the
6
+ // default online-only filter is a no-op for these agents.
7
+ function allOnline(...names: string[]) {
8
+ const agents: Record<string, unknown> = {};
9
+ for (const name of names) {
10
+ agents[name] = { agentName: name, instances: [], onlineCount: 1, taskCount: 0 };
11
+ }
12
+ return { agents };
13
+ }
14
+
15
+ describe('search_agent', () => {
16
+ it('forwards the query to the registry helper as `q`', async () => {
17
+ const { deps, mocks } = makeFakeDeps({
18
+ apiKey: 'sk-test',
19
+ baseUrl: 'http://api.test',
20
+ });
21
+
22
+ await searchAgents(
23
+ { query: 'translate', tag: 'language', listing: 'private', limit: 25 },
24
+ deps,
25
+ );
26
+
27
+ expect(mocks.listAgents).toHaveBeenCalledWith({
28
+ baseUrl: 'http://api.test',
29
+ apiKey: 'sk-test',
30
+ q: 'translate',
31
+ tag: 'language',
32
+ listing: 'private',
33
+ maxAgents: 25,
34
+ });
35
+ });
36
+
37
+ it('renders matching agents with the query in the header', async () => {
38
+ const { deps } = makeFakeDeps({
39
+ listAgentsResult: {
40
+ agents: [
41
+ {
42
+ agentName: 'alice',
43
+ name: 'Alice',
44
+ listing: 'public',
45
+ tags: [{ id: 'translate', name: 'Translate' }],
46
+ },
47
+ ],
48
+ totalCount: 1,
49
+ },
50
+ agentStatusResult: allOnline('alice'),
51
+ });
52
+
53
+ const res = await searchAgents({ query: 'trans' }, deps);
54
+ const lines = res.content[0].text.split('\n');
55
+ expect(lines).toEqual([
56
+ 'Agents matching "trans" (1 online of 1 total):',
57
+ 'alice | Alice | public | Translate',
58
+ ]);
59
+ });
60
+
61
+ it('trims whitespace from the query before sending it', async () => {
62
+ const { deps, mocks } = makeFakeDeps();
63
+
64
+ await searchAgents({ query: ' hello ' }, deps);
65
+
66
+ expect(mocks.listAgents).toHaveBeenCalledWith(
67
+ expect.objectContaining({ q: 'hello' }),
68
+ );
69
+ });
70
+
71
+ it('rejects an empty (or whitespace-only) query without calling the registry', async () => {
72
+ const { deps, mocks } = makeFakeDeps();
73
+
74
+ const res = await searchAgents({ query: ' ' }, deps);
75
+ expect(res.isError).toBe(true);
76
+ expect(res.content[0].text).toContain('must not be empty');
77
+ expect(mocks.listAgents).not.toHaveBeenCalled();
78
+ });
79
+
80
+ it('drops offline matches by default, keeping only online ones', async () => {
81
+ const { deps } = makeFakeDeps({
82
+ listAgentsResult: {
83
+ agents: [
84
+ { agentName: 'online', name: 'Online' },
85
+ { agentName: 'offline', name: 'Offline' },
86
+ ],
87
+ totalCount: 2,
88
+ },
89
+ agentStatusResult: {
90
+ agents: {
91
+ online: { agentName: 'online', instances: [], onlineCount: 1, taskCount: 0 },
92
+ offline: { agentName: 'offline', instances: [], onlineCount: 0, taskCount: 0 },
93
+ },
94
+ },
95
+ });
96
+
97
+ const res = await searchAgents({ query: 'foo' }, deps);
98
+ const lines = res.content[0].text.split('\n');
99
+ expect(lines).toEqual([
100
+ 'Agents matching "foo" (1 online of 2 total):',
101
+ 'online | Online | public | ',
102
+ ]);
103
+ });
104
+
105
+ it('includes offline matches and skips the status check when includeOffline is true', async () => {
106
+ const { deps, mocks } = makeFakeDeps({
107
+ listAgentsResult: {
108
+ agents: [{ agentName: 'online' }, { agentName: 'offline' }],
109
+ totalCount: 2,
110
+ },
111
+ });
112
+
113
+ const res = await searchAgents({ query: 'foo', includeOffline: true }, deps);
114
+ expect(res.content[0].text.split('\n')[0]).toBe(
115
+ 'Agents matching "foo" (2 of 2 total):',
116
+ );
117
+ expect(mocks.fetchAgentStatus).not.toHaveBeenCalled();
118
+ });
119
+ });