@blocks-network/mcp-server 0.1.66 → 0.1.70

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/dist/index.js CHANGED
@@ -111,8 +111,12 @@ 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. 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.', {
114
+ server.tool('list_agents', 'List available agents in the Blocks Network registry. To find every agent published by a particular provider/organization (e.g. "all agents from Hamilton"), use this tool with the `provider` parameter — it is the correct tool for provider-scoped browsing and needs no search query. 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
+ provider: z
117
+ .string()
118
+ .optional()
119
+ .describe('Filter to agents published by this provider (the publishing organization\'s name), matched case-insensitively as a substring (e.g. "hamilton" matches "Hamilton Labs"). This is the reliable way to scope results to a provider — prefer it over typing the provider name into a free-text query, which only fuzzy-matches names/descriptions.'),
116
120
  listing: z
117
121
  .enum(['public', 'private'])
118
122
  .optional()
@@ -126,8 +130,15 @@ server.tool('list_agents', 'List available agents in the Blocks Network registry
126
130
  .optional()
127
131
  .describe('Include agents with no online instances (default: false)'),
128
132
  }, (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'),
133
+ 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"). To restrict results to a specific provider/organization, set the `provider` parameter rather than putting the provider name in the query; `query` is optional, so you can search by `provider` and/or `tag` alone (e.g. provider="Hamilton" with no query returns every Hamilton agent). At least one of `query`, `provider`, or `tag` must be supplied. 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.', {
134
+ query: z
135
+ .string()
136
+ .optional()
137
+ .describe('Free-text search query. Optional, but at least one of `query`, `provider`, or `tag` must be given. Omit it to browse a provider or tag with no search terms (e.g. provider="Hamilton" alone returns every Hamilton agent).'),
138
+ provider: z
139
+ .string()
140
+ .optional()
141
+ .describe('Restrict matches to agents published by this provider (the publishing organization\'s name), matched case-insensitively as a substring (e.g. "hamilton" matches "Hamilton Labs"). Use this parameter to scope by provider instead of typing the provider name into `query`, which only fuzzy-matches names/descriptions and is unreliable.'),
131
142
  tag: z.string().optional().describe('Additionally filter by tag slug'),
132
143
  listing: z
133
144
  .enum(['public', 'private'])
@@ -11,6 +11,8 @@ export interface AgentListEntry {
11
11
  description?: string;
12
12
  listing?: string;
13
13
  billingMode?: string;
14
+ /** Provider (publishing organization) name, as returned by the backend. */
15
+ orgName?: string;
14
16
  tags?: Array<{
15
17
  id: string;
16
18
  name: string;
@@ -21,6 +23,12 @@ export interface ListAgentsOptions {
21
23
  apiKey?: string;
22
24
  /** Free-text search query (`q`); matches agent name, description, tags, etc. */
23
25
  q?: string;
26
+ /**
27
+ * Filter to agents published by a provider whose organization name matches
28
+ * this value (case-insensitive substring). Composed into the `q` parameter
29
+ * as a `provider:"…"` qualifier, so it combines (AND) with any `q` text.
30
+ */
31
+ provider?: string;
24
32
  tag?: string;
25
33
  listing?: 'public' | 'private';
26
34
  /** Page size for a single request. The backend caps this at 100. */
@@ -39,6 +47,18 @@ export interface ListAgentsResult {
39
47
  }
40
48
  /** Backend maximum page size for GET /api/v1/registry/agents. */
41
49
  export declare const REGISTRY_PAGE_SIZE = 100;
50
+ /**
51
+ * Combine the free-text query with a provider-name filter into a single `q`
52
+ * value the registry's search parser understands.
53
+ *
54
+ * The backend exposes provider-by-name only through the `provider:` search
55
+ * qualifier (matched as a case-insensitive substring of the organization
56
+ * name); the structured `provider` query param takes org UUIDs instead, which
57
+ * an MCP caller doesn't have. We always quote the value so names containing
58
+ * spaces survive the tokenizer, and strip embedded quotes so a stray `"`
59
+ * can't unbalance the qualifier.
60
+ */
61
+ export declare function composeSearchQuery(q: string | undefined, provider: string | undefined): string | undefined;
42
62
  export declare function listAgentsAuthenticated(opts: ListAgentsOptions): Promise<ListAgentsResult>;
43
63
  /**
44
64
  * Fetch every agent by following the `next` cursor until it's null/absent.
@@ -8,12 +8,32 @@
8
8
  import { PROTOCOL_VERSION_HEADER, CURRENT_PROTOCOL_VERSION, } from './protocol-headers.js';
9
9
  /** Backend maximum page size for GET /api/v1/registry/agents. */
10
10
  export const REGISTRY_PAGE_SIZE = 100;
11
+ /**
12
+ * Combine the free-text query with a provider-name filter into a single `q`
13
+ * value the registry's search parser understands.
14
+ *
15
+ * The backend exposes provider-by-name only through the `provider:` search
16
+ * qualifier (matched as a case-insensitive substring of the organization
17
+ * name); the structured `provider` query param takes org UUIDs instead, which
18
+ * an MCP caller doesn't have. We always quote the value so names containing
19
+ * spaces survive the tokenizer, and strip embedded quotes so a stray `"`
20
+ * can't unbalance the qualifier.
21
+ */
22
+ export function composeSearchQuery(q, provider) {
23
+ const trimmedProvider = provider?.trim();
24
+ if (!trimmedProvider)
25
+ return q;
26
+ const quoted = `provider:"${trimmedProvider.replace(/"/g, '')}"`;
27
+ const trimmedQ = q?.trim();
28
+ return trimmedQ ? `${trimmedQ} ${quoted}` : quoted;
29
+ }
11
30
  /** Safety backstop so a misbehaving (never-null) cursor can't loop forever. */
12
31
  const MAX_PAGES = 1000;
13
32
  export async function listAgentsAuthenticated(opts) {
14
33
  const params = new URLSearchParams({ include: 'full' });
15
- if (opts.q)
16
- params.set('q', opts.q);
34
+ const q = composeSearchQuery(opts.q, opts.provider);
35
+ if (q)
36
+ params.set('q', q);
17
37
  if (opts.tag)
18
38
  params.set('tag', opts.tag);
19
39
  if (opts.listing) {
package/dist/tools.d.ts CHANGED
@@ -107,6 +107,8 @@ export interface ToolDeps {
107
107
  apiKey?: string;
108
108
  /** Free-text search query (`q`); matches agent name, description, tags, etc. */
109
109
  q?: string;
110
+ /** Filter to agents whose provider (organization) name matches (substring). */
111
+ provider?: string;
110
112
  tag?: string;
111
113
  listing?: 'public' | 'private';
112
114
  /** Optional cap on the total number of agents to fetch across all pages. */
@@ -159,6 +161,8 @@ export interface CancelTaskParams {
159
161
  }
160
162
  export interface ListAgentsParams {
161
163
  tag?: string;
164
+ /** Filter to agents whose provider (organization) name matches (substring, case-insensitive). */
165
+ provider?: string;
162
166
  listing?: 'public' | 'private';
163
167
  limit?: number;
164
168
  /**
@@ -170,8 +174,14 @@ export interface ListAgentsParams {
170
174
  includeOffline?: boolean;
171
175
  }
172
176
  export interface SearchAgentsParams {
173
- /** Free-text search query; matches agent name, description, tags, etc. */
174
- query: string;
177
+ /**
178
+ * Free-text search query; matches agent name, description, tags, etc.
179
+ * Optional, but at least one of `query`, `provider`, or `tag` must be
180
+ * supplied — a search with no constraints at all is rejected.
181
+ */
182
+ query?: string;
183
+ /** Filter to agents whose provider (organization) name matches (substring, case-insensitive). */
184
+ provider?: string;
175
185
  tag?: string;
176
186
  listing?: 'public' | 'private';
177
187
  limit?: number;
package/dist/tools.js CHANGED
@@ -162,6 +162,7 @@ export async function listAgents(params, deps) {
162
162
  const result = await deps.listAgents({
163
163
  baseUrl,
164
164
  apiKey,
165
+ provider: params.provider,
165
166
  tag: params.tag,
166
167
  listing: params.listing,
167
168
  maxAgents: params.limit,
@@ -185,10 +186,21 @@ export async function listAgents(params, deps) {
185
186
  // search_agent
186
187
  // ============================================================================
187
188
  export async function searchAgents(params, deps) {
188
- const query = params.query.trim();
189
- if (!query) {
189
+ const query = params.query?.trim();
190
+ const provider = params.provider?.trim();
191
+ const tag = params.tag?.trim();
192
+ // A search needs at least one constraint. Provider- or tag-only searches are
193
+ // valid (e.g. "every agent from Hamilton"); only a completely empty request
194
+ // is rejected, since that's just an unfiltered list and should use
195
+ // list_agents instead.
196
+ if (!query && !provider && !tag) {
190
197
  return {
191
- content: [{ type: 'text', text: 'Search query must not be empty.' }],
198
+ content: [
199
+ {
200
+ type: 'text',
201
+ text: 'Provide at least one of `query`, `provider`, or `tag` to search. To list every agent without a filter, use list_agents.',
202
+ },
203
+ ],
192
204
  isError: true,
193
205
  };
194
206
  }
@@ -198,27 +210,48 @@ export async function searchAgents(params, deps) {
198
210
  baseUrl,
199
211
  apiKey,
200
212
  q: query,
201
- tag: params.tag,
213
+ provider,
214
+ tag,
202
215
  listing: params.listing,
203
216
  maxAgents: params.limit,
204
217
  });
205
218
  const total = result.totalCount ?? result.agents.length;
219
+ // Describe whatever constraints were actually applied so the header reflects
220
+ // a provider/tag-only search as accurately as a free-text one.
221
+ const criteria = describeSearchCriteria(query, provider, tag);
206
222
  // Mirror list_agents: default to online-only so we never surface agents
207
223
  // that can't actually take a task; `includeOffline` opts back into the
208
224
  // full set of matches.
209
225
  if (params.includeOffline) {
210
226
  const lines = result.agents.map(formatAgentRow);
211
- const header = `Agents matching "${query}" (${result.agents.length} of ${total} total):`;
227
+ const header = `Agents ${criteria} (${result.agents.length} of ${total} total):`;
212
228
  return { content: [{ type: 'text', text: [header, ...lines].join('\n') }] };
213
229
  }
214
230
  const online = await filterOnlineAgents(result.agents, { baseUrl, apiKey }, deps);
215
231
  const lines = online.map(formatAgentRow);
216
- const header = `Agents matching "${query}" (${online.length} online of ${total} total):`;
232
+ const header = `Agents ${criteria} (${online.length} online of ${total} total):`;
217
233
  return { content: [{ type: 'text', text: [header, ...lines].join('\n') }] };
218
234
  }
219
235
  function formatAgentRow(a) {
220
236
  const tags = a.tags?.map((t) => t.name).join(', ') ?? '';
221
- return `${a.agentName} | ${a.name ?? a.agentName} | ${a.listing ?? 'public'} | ${tags}`;
237
+ const provider = a.orgName ?? '';
238
+ return `${a.agentName} | ${a.name ?? a.agentName} | ${provider} | ${a.listing ?? 'public'} | ${tags}`;
239
+ }
240
+ /**
241
+ * Build the result-header phrase from whichever search constraints were
242
+ * applied, e.g. `matching "trans"`, `from provider "Hamilton"`, or
243
+ * `matching "trans" from provider "Hamilton" with tag "data"`. At least one
244
+ * constraint is always present (the handler rejects an empty search).
245
+ */
246
+ function describeSearchCriteria(query, provider, tag) {
247
+ const parts = [];
248
+ if (query)
249
+ parts.push(`matching "${query}"`);
250
+ if (provider)
251
+ parts.push(`from provider "${provider}"`);
252
+ if (tag)
253
+ parts.push(`with tag "${tag}"`);
254
+ return parts.join(' ');
222
255
  }
223
256
  /**
224
257
  * Keep only agents that have at least one online instance, as reported by
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blocks-network/mcp-server",
3
- "version": "0.1.66",
3
+ "version": "0.1.70",
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
@@ -194,9 +194,15 @@ server.tool(
194
194
 
195
195
  server.tool(
196
196
  'list_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
+ 'List available agents in the Blocks Network registry. To find every agent published by a particular provider/organization (e.g. "all agents from Hamilton"), use this tool with the `provider` parameter — it is the correct tool for provider-scoped browsing and needs no search query. 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.',
198
198
  {
199
199
  tag: z.string().optional().describe('Filter by tag slug'),
200
+ provider: z
201
+ .string()
202
+ .optional()
203
+ .describe(
204
+ 'Filter to agents published by this provider (the publishing organization\'s name), matched case-insensitively as a substring (e.g. "hamilton" matches "Hamilton Labs"). This is the reliable way to scope results to a provider — prefer it over typing the provider name into a free-text query, which only fuzzy-matches names/descriptions.',
205
+ ),
200
206
  listing: z
201
207
  .enum(['public', 'private'])
202
208
  .optional()
@@ -215,9 +221,20 @@ server.tool(
215
221
 
216
222
  server.tool(
217
223
  '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.',
224
+ '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"). To restrict results to a specific provider/organization, set the `provider` parameter rather than putting the provider name in the query; `query` is optional, so you can search by `provider` and/or `tag` alone (e.g. provider="Hamilton" with no query returns every Hamilton agent). At least one of `query`, `provider`, or `tag` must be supplied. 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
225
  {
220
- query: z.string().describe('Free-text search query'),
226
+ query: z
227
+ .string()
228
+ .optional()
229
+ .describe(
230
+ 'Free-text search query. Optional, but at least one of `query`, `provider`, or `tag` must be given. Omit it to browse a provider or tag with no search terms (e.g. provider="Hamilton" alone returns every Hamilton agent).',
231
+ ),
232
+ provider: z
233
+ .string()
234
+ .optional()
235
+ .describe(
236
+ 'Restrict matches to agents published by this provider (the publishing organization\'s name), matched case-insensitively as a substring (e.g. "hamilton" matches "Hamilton Labs"). Use this parameter to scope by provider instead of typing the provider name into `query`, which only fuzzy-matches names/descriptions and is unreliable.',
237
+ ),
221
238
  tag: z.string().optional().describe('Additionally filter by tag slug'),
222
239
  listing: z
223
240
  .enum(['public', 'private'])
@@ -17,6 +17,8 @@ export interface AgentListEntry {
17
17
  description?: string;
18
18
  listing?: string;
19
19
  billingMode?: string;
20
+ /** Provider (publishing organization) name, as returned by the backend. */
21
+ orgName?: string;
20
22
  tags?: Array<{ id: string; name: string }>;
21
23
  }
22
24
 
@@ -25,6 +27,12 @@ export interface ListAgentsOptions {
25
27
  apiKey?: string;
26
28
  /** Free-text search query (`q`); matches agent name, description, tags, etc. */
27
29
  q?: string;
30
+ /**
31
+ * Filter to agents published by a provider whose organization name matches
32
+ * this value (case-insensitive substring). Composed into the `q` parameter
33
+ * as a `provider:"…"` qualifier, so it combines (AND) with any `q` text.
34
+ */
35
+ provider?: string;
28
36
  tag?: string;
29
37
  listing?: 'public' | 'private';
30
38
  /** Page size for a single request. The backend caps this at 100. */
@@ -46,6 +54,28 @@ export interface ListAgentsResult {
46
54
  /** Backend maximum page size for GET /api/v1/registry/agents. */
47
55
  export const REGISTRY_PAGE_SIZE = 100;
48
56
 
57
+ /**
58
+ * Combine the free-text query with a provider-name filter into a single `q`
59
+ * value the registry's search parser understands.
60
+ *
61
+ * The backend exposes provider-by-name only through the `provider:` search
62
+ * qualifier (matched as a case-insensitive substring of the organization
63
+ * name); the structured `provider` query param takes org UUIDs instead, which
64
+ * an MCP caller doesn't have. We always quote the value so names containing
65
+ * spaces survive the tokenizer, and strip embedded quotes so a stray `"`
66
+ * can't unbalance the qualifier.
67
+ */
68
+ export function composeSearchQuery(
69
+ q: string | undefined,
70
+ provider: string | undefined,
71
+ ): string | undefined {
72
+ const trimmedProvider = provider?.trim();
73
+ if (!trimmedProvider) return q;
74
+ const quoted = `provider:"${trimmedProvider.replace(/"/g, '')}"`;
75
+ const trimmedQ = q?.trim();
76
+ return trimmedQ ? `${trimmedQ} ${quoted}` : quoted;
77
+ }
78
+
49
79
  /** Safety backstop so a misbehaving (never-null) cursor can't loop forever. */
50
80
  const MAX_PAGES = 1000;
51
81
 
@@ -53,7 +83,8 @@ export async function listAgentsAuthenticated(
53
83
  opts: ListAgentsOptions,
54
84
  ): Promise<ListAgentsResult> {
55
85
  const params = new URLSearchParams({ include: 'full' });
56
- if (opts.q) params.set('q', opts.q);
86
+ const q = composeSearchQuery(opts.q, opts.provider);
87
+ if (q) params.set('q', q);
57
88
  if (opts.tag) params.set('tag', opts.tag);
58
89
  if (opts.listing) {
59
90
  params.set('listing', opts.listing);
package/src/tools.ts CHANGED
@@ -126,6 +126,8 @@ export interface ToolDeps {
126
126
  apiKey?: string;
127
127
  /** Free-text search query (`q`); matches agent name, description, tags, etc. */
128
128
  q?: string;
129
+ /** Filter to agents whose provider (organization) name matches (substring). */
130
+ provider?: string;
129
131
  tag?: string;
130
132
  listing?: 'public' | 'private';
131
133
  /** Optional cap on the total number of agents to fetch across all pages. */
@@ -228,6 +230,8 @@ export interface CancelTaskParams {
228
230
 
229
231
  export interface ListAgentsParams {
230
232
  tag?: string;
233
+ /** Filter to agents whose provider (organization) name matches (substring, case-insensitive). */
234
+ provider?: string;
231
235
  listing?: 'public' | 'private';
232
236
  limit?: number;
233
237
  /**
@@ -240,8 +244,14 @@ export interface ListAgentsParams {
240
244
  }
241
245
 
242
246
  export interface SearchAgentsParams {
243
- /** Free-text search query; matches agent name, description, tags, etc. */
244
- query: string;
247
+ /**
248
+ * Free-text search query; matches agent name, description, tags, etc.
249
+ * Optional, but at least one of `query`, `provider`, or `tag` must be
250
+ * supplied — a search with no constraints at all is rejected.
251
+ */
252
+ query?: string;
253
+ /** Filter to agents whose provider (organization) name matches (substring, case-insensitive). */
254
+ provider?: string;
245
255
  tag?: string;
246
256
  listing?: 'public' | 'private';
247
257
  limit?: number;
@@ -438,6 +448,7 @@ export async function listAgents(
438
448
  const result = await deps.listAgents({
439
449
  baseUrl,
440
450
  apiKey,
451
+ provider: params.provider,
441
452
  tag: params.tag,
442
453
  listing: params.listing,
443
454
  maxAgents: params.limit,
@@ -469,10 +480,22 @@ export async function searchAgents(
469
480
  params: SearchAgentsParams,
470
481
  deps: ToolDeps,
471
482
  ): Promise<ToolResult> {
472
- const query = params.query.trim();
473
- if (!query) {
483
+ const query = params.query?.trim();
484
+ const provider = params.provider?.trim();
485
+ const tag = params.tag?.trim();
486
+
487
+ // A search needs at least one constraint. Provider- or tag-only searches are
488
+ // valid (e.g. "every agent from Hamilton"); only a completely empty request
489
+ // is rejected, since that's just an unfiltered list and should use
490
+ // list_agents instead.
491
+ if (!query && !provider && !tag) {
474
492
  return {
475
- content: [{ type: 'text', text: 'Search query must not be empty.' }],
493
+ content: [
494
+ {
495
+ type: 'text',
496
+ text: 'Provide at least one of `query`, `provider`, or `tag` to search. To list every agent without a filter, use list_agents.',
497
+ },
498
+ ],
476
499
  isError: true,
477
500
  };
478
501
  }
@@ -483,31 +506,55 @@ export async function searchAgents(
483
506
  baseUrl,
484
507
  apiKey,
485
508
  q: query,
486
- tag: params.tag,
509
+ provider,
510
+ tag,
487
511
  listing: params.listing,
488
512
  maxAgents: params.limit,
489
513
  });
490
514
 
491
515
  const total = result.totalCount ?? result.agents.length;
492
516
 
517
+ // Describe whatever constraints were actually applied so the header reflects
518
+ // a provider/tag-only search as accurately as a free-text one.
519
+ const criteria = describeSearchCriteria(query, provider, tag);
520
+
493
521
  // Mirror list_agents: default to online-only so we never surface agents
494
522
  // that can't actually take a task; `includeOffline` opts back into the
495
523
  // full set of matches.
496
524
  if (params.includeOffline) {
497
525
  const lines = result.agents.map(formatAgentRow);
498
- const header = `Agents matching "${query}" (${result.agents.length} of ${total} total):`;
526
+ const header = `Agents ${criteria} (${result.agents.length} of ${total} total):`;
499
527
  return { content: [{ type: 'text', text: [header, ...lines].join('\n') }] };
500
528
  }
501
529
 
502
530
  const online = await filterOnlineAgents(result.agents, { baseUrl, apiKey }, deps);
503
531
  const lines = online.map(formatAgentRow);
504
- const header = `Agents matching "${query}" (${online.length} online of ${total} total):`;
532
+ const header = `Agents ${criteria} (${online.length} online of ${total} total):`;
505
533
  return { content: [{ type: 'text', text: [header, ...lines].join('\n') }] };
506
534
  }
507
535
 
508
536
  function formatAgentRow(a: ListAgentsResult['agents'][number]): string {
509
537
  const tags = a.tags?.map((t) => t.name).join(', ') ?? '';
510
- return `${a.agentName} | ${a.name ?? a.agentName} | ${a.listing ?? 'public'} | ${tags}`;
538
+ const provider = a.orgName ?? '';
539
+ return `${a.agentName} | ${a.name ?? a.agentName} | ${provider} | ${a.listing ?? 'public'} | ${tags}`;
540
+ }
541
+
542
+ /**
543
+ * Build the result-header phrase from whichever search constraints were
544
+ * applied, e.g. `matching "trans"`, `from provider "Hamilton"`, or
545
+ * `matching "trans" from provider "Hamilton" with tag "data"`. At least one
546
+ * constraint is always present (the handler rejects an empty search).
547
+ */
548
+ function describeSearchCriteria(
549
+ query: string | undefined,
550
+ provider: string | undefined,
551
+ tag: string | undefined,
552
+ ): string {
553
+ const parts: string[] = [];
554
+ if (query) parts.push(`matching "${query}"`);
555
+ if (provider) parts.push(`from provider "${provider}"`);
556
+ if (tag) parts.push(`with tag "${tag}"`);
557
+ return parts.join(' ');
511
558
  }
512
559
 
513
560
  /**
@@ -21,6 +21,7 @@ describe('list_agents', () => {
21
21
  agentName: 'alice',
22
22
  name: 'Alice',
23
23
  listing: 'public',
24
+ orgName: 'Hamilton Labs',
24
25
  tags: [
25
26
  { id: 'translate', name: 'Translate' },
26
27
  { id: 'summarize', name: 'Summarize' },
@@ -35,10 +36,11 @@ describe('list_agents', () => {
35
36
 
36
37
  const res = await listAgents({}, deps);
37
38
  const lines = res.content[0].text.split('\n');
39
+ // Row format: agentName | displayName | provider | listing | tags
38
40
  expect(lines).toEqual([
39
41
  'Agents (2 online of 2 total):',
40
- 'alice | Alice | public | Translate, Summarize',
41
- 'bob | bob | private | ',
42
+ 'alice | Alice | Hamilton Labs | public | Translate, Summarize',
43
+ 'bob | bob | | private | ',
42
44
  ]);
43
45
  });
44
46
 
@@ -62,6 +64,16 @@ describe('list_agents', () => {
62
64
  });
63
65
  });
64
66
 
67
+ it('forwards the provider filter to the registry helper', async () => {
68
+ const { deps, mocks } = makeFakeDeps();
69
+
70
+ await listAgents({ provider: 'Acme Corp' }, deps);
71
+
72
+ expect(mocks.listAgents).toHaveBeenCalledWith(
73
+ expect.objectContaining({ provider: 'Acme Corp' }),
74
+ );
75
+ });
76
+
65
77
  it('defaults to "public" for missing listing and uses agentName when name is absent', async () => {
66
78
  const { deps } = makeFakeDeps({
67
79
  listAgentsResult: {
@@ -72,7 +84,7 @@ describe('list_agents', () => {
72
84
  });
73
85
 
74
86
  const res = await listAgents({}, deps);
75
- expect(res.content[0].text).toContain('naked | naked | public |');
87
+ expect(res.content[0].text).toContain('naked | naked | | public |');
76
88
  });
77
89
 
78
90
  it('uses the online agent count for the header, falling back to length for total', async () => {
@@ -124,7 +136,7 @@ describe('list_agents', () => {
124
136
  const lines = res.content[0].text.split('\n');
125
137
  expect(lines).toEqual([
126
138
  'Agents (1 online of 2 total):',
127
- 'online | Online | public | ',
139
+ 'online | Online | | public | ',
128
140
  ]);
129
141
  expect(mocks.fetchAgentStatus).toHaveBeenCalledWith({
130
142
  baseUrl: 'http://api.test',
@@ -165,7 +177,7 @@ describe('list_agents', () => {
165
177
  const lines = res.content[0].text.split('\n');
166
178
  expect(lines).toEqual([
167
179
  'Agents (1 online of 2 total):',
168
- 'valid_name | valid_name | public | ',
180
+ 'valid_name | valid_name | | public | ',
169
181
  ]);
170
182
  });
171
183
  });
@@ -1,5 +1,6 @@
1
1
  import { describe, it, expect, vi } from 'vitest';
2
2
  import {
3
+ composeSearchQuery,
3
4
  listAgentsAuthenticated,
4
5
  listAllAgentsAuthenticated,
5
6
  } from '../src/registry-list.js';
@@ -16,6 +17,36 @@ function mockResponse(body: unknown, ok = true, status = 200): Response {
16
17
  } as Response;
17
18
  }
18
19
 
20
+ describe('composeSearchQuery', () => {
21
+ it('returns the query unchanged when no provider is given', () => {
22
+ expect(composeSearchQuery('translate', undefined)).toBe('translate');
23
+ expect(composeSearchQuery(undefined, undefined)).toBeUndefined();
24
+ });
25
+
26
+ it('emits a quoted provider qualifier when only a provider is given', () => {
27
+ expect(composeSearchQuery(undefined, 'Acme Corp')).toBe('provider:"Acme Corp"');
28
+ });
29
+
30
+ it('combines free text and provider with a space (AND)', () => {
31
+ expect(composeSearchQuery('translate', 'Acme')).toBe('translate provider:"Acme"');
32
+ });
33
+
34
+ it('trims whitespace around both inputs', () => {
35
+ expect(composeSearchQuery(' translate ', ' Acme ')).toBe(
36
+ 'translate provider:"Acme"',
37
+ );
38
+ });
39
+
40
+ it('treats a blank provider as absent', () => {
41
+ expect(composeSearchQuery('translate', ' ')).toBe('translate');
42
+ expect(composeSearchQuery(undefined, ' ')).toBeUndefined();
43
+ });
44
+
45
+ it('strips embedded quotes so the qualifier stays balanced', () => {
46
+ expect(composeSearchQuery(undefined, 'Ac"me')).toBe('provider:"Acme"');
47
+ });
48
+ });
49
+
19
50
  describe('listAgentsAuthenticated', () => {
20
51
  it('sends Blocks-Protocol-Version header on every request', async () => {
21
52
  const fetchImpl = vi.fn().mockResolvedValue(
@@ -75,6 +106,39 @@ describe('listAgentsAuthenticated', () => {
75
106
  expect(parsed.searchParams.get('limit')).toBe('50');
76
107
  });
77
108
 
109
+ it('folds the provider filter into the q param as a quoted qualifier', async () => {
110
+ const fetchImpl = vi.fn().mockResolvedValue(
111
+ mockResponse({ agents: [], totalCount: 0 }),
112
+ );
113
+
114
+ await listAgentsAuthenticated({
115
+ baseUrl: 'http://api.test',
116
+ q: 'translate',
117
+ provider: 'Acme Corp',
118
+ fetchImpl: fetchImpl as unknown as typeof fetch,
119
+ });
120
+
121
+ const [url] = fetchImpl.mock.calls[0];
122
+ expect(new URL(String(url)).searchParams.get('q')).toBe(
123
+ 'translate provider:"Acme Corp"',
124
+ );
125
+ });
126
+
127
+ it('sets q to just the provider qualifier when no free text is given', async () => {
128
+ const fetchImpl = vi.fn().mockResolvedValue(
129
+ mockResponse({ agents: [], totalCount: 0 }),
130
+ );
131
+
132
+ await listAgentsAuthenticated({
133
+ baseUrl: 'http://api.test',
134
+ provider: 'Acme',
135
+ fetchImpl: fetchImpl as unknown as typeof fetch,
136
+ });
137
+
138
+ const [url] = fetchImpl.mock.calls[0];
139
+ expect(new URL(String(url)).searchParams.get('q')).toBe('provider:"Acme"');
140
+ });
141
+
78
142
  it('does not set scope=owned for public listing', async () => {
79
143
  const fetchImpl = vi.fn().mockResolvedValue(
80
144
  mockResponse({ agents: [], totalCount: 0 }),
@@ -34,6 +34,16 @@ describe('search_agent', () => {
34
34
  });
35
35
  });
36
36
 
37
+ it('forwards the provider filter to the registry helper', async () => {
38
+ const { deps, mocks } = makeFakeDeps();
39
+
40
+ await searchAgents({ query: 'translate', provider: 'Acme Corp' }, deps);
41
+
42
+ expect(mocks.listAgents).toHaveBeenCalledWith(
43
+ expect.objectContaining({ q: 'translate', provider: 'Acme Corp' }),
44
+ );
45
+ });
46
+
37
47
  it('renders matching agents with the query in the header', async () => {
38
48
  const { deps } = makeFakeDeps({
39
49
  listAgentsResult: {
@@ -42,6 +52,7 @@ describe('search_agent', () => {
42
52
  agentName: 'alice',
43
53
  name: 'Alice',
44
54
  listing: 'public',
55
+ orgName: 'Hamilton Labs',
45
56
  tags: [{ id: 'translate', name: 'Translate' }],
46
57
  },
47
58
  ],
@@ -52,9 +63,10 @@ describe('search_agent', () => {
52
63
 
53
64
  const res = await searchAgents({ query: 'trans' }, deps);
54
65
  const lines = res.content[0].text.split('\n');
66
+ // Row format: agentName | displayName | provider | listing | tags
55
67
  expect(lines).toEqual([
56
68
  'Agents matching "trans" (1 online of 1 total):',
57
- 'alice | Alice | public | Translate',
69
+ 'alice | Alice | Hamilton Labs | public | Translate',
58
70
  ]);
59
71
  });
60
72
 
@@ -68,15 +80,65 @@ describe('search_agent', () => {
68
80
  );
69
81
  });
70
82
 
71
- it('rejects an empty (or whitespace-only) query without calling the registry', async () => {
83
+ it('rejects a search with no query, provider, or tag without calling the registry', async () => {
72
84
  const { deps, mocks } = makeFakeDeps();
73
85
 
74
86
  const res = await searchAgents({ query: ' ' }, deps);
75
87
  expect(res.isError).toBe(true);
76
- expect(res.content[0].text).toContain('must not be empty');
88
+ expect(res.content[0].text).toContain('at least one of');
77
89
  expect(mocks.listAgents).not.toHaveBeenCalled();
78
90
  });
79
91
 
92
+ it('allows a provider-only search with no query', async () => {
93
+ const { deps, mocks } = makeFakeDeps({
94
+ listAgentsResult: {
95
+ agents: [{ agentName: 'alice', name: 'Alice', listing: 'public' }],
96
+ totalCount: 1,
97
+ },
98
+ agentStatusResult: allOnline('alice'),
99
+ });
100
+
101
+ const res = await searchAgents({ provider: 'Hamilton' }, deps);
102
+
103
+ expect(res.isError).toBeUndefined();
104
+ expect(mocks.listAgents).toHaveBeenCalledWith(
105
+ expect.objectContaining({ q: undefined, provider: 'Hamilton' }),
106
+ );
107
+ expect(res.content[0].text.split('\n')[0]).toBe(
108
+ 'Agents from provider "Hamilton" (1 online of 1 total):',
109
+ );
110
+ });
111
+
112
+ it('allows a tag-only search with no query', async () => {
113
+ const { deps, mocks } = makeFakeDeps({
114
+ listAgentsResult: { agents: [], totalCount: 0 },
115
+ });
116
+
117
+ const res = await searchAgents({ tag: 'data' }, deps);
118
+
119
+ expect(res.isError).toBeUndefined();
120
+ expect(mocks.listAgents).toHaveBeenCalledWith(
121
+ expect.objectContaining({ tag: 'data' }),
122
+ );
123
+ expect(res.content[0].text.split('\n')[0]).toBe(
124
+ 'Agents with tag "data" (0 online of 0 total):',
125
+ );
126
+ });
127
+
128
+ it('combines query, provider, and tag in the header', async () => {
129
+ const { deps } = makeFakeDeps({
130
+ listAgentsResult: { agents: [], totalCount: 0 },
131
+ });
132
+
133
+ const res = await searchAgents(
134
+ { query: 'trans', provider: 'Hamilton', tag: 'data' },
135
+ deps,
136
+ );
137
+ expect(res.content[0].text.split('\n')[0]).toBe(
138
+ 'Agents matching "trans" from provider "Hamilton" with tag "data" (0 online of 0 total):',
139
+ );
140
+ });
141
+
80
142
  it('drops offline matches by default, keeping only online ones', async () => {
81
143
  const { deps } = makeFakeDeps({
82
144
  listAgentsResult: {
@@ -98,7 +160,7 @@ describe('search_agent', () => {
98
160
  const lines = res.content[0].text.split('\n');
99
161
  expect(lines).toEqual([
100
162
  'Agents matching "foo" (1 online of 2 total):',
101
- 'online | Online | public | ',
163
+ 'online | Online | | public | ',
102
164
  ]);
103
165
  });
104
166