@blocks-network/mcp-server 0.1.65 → 0.1.67
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 +14 -3
- package/dist/registry-list.d.ts +20 -0
- package/dist/registry-list.js +22 -2
- package/dist/tools.d.ts +12 -2
- package/dist/tools.js +40 -7
- package/package.json +1 -1
- package/src/index.ts +20 -3
- package/src/registry-list.ts +32 -1
- package/src/tools.ts +56 -9
- package/tests/list-agents.test.ts +17 -5
- package/tests/registry-list.test.ts +64 -0
- package/tests/search-agent.test.ts +66 -4
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
|
|
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'])
|
package/dist/registry-list.d.ts
CHANGED
|
@@ -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.
|
package/dist/registry-list.js
CHANGED
|
@@ -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
|
-
|
|
16
|
-
|
|
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
|
-
/**
|
|
174
|
-
|
|
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
|
|
189
|
-
|
|
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: [
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
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
|
|
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'])
|
package/src/registry-list.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
244
|
-
|
|
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
|
|
473
|
-
|
|
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: [
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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('
|
|
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
|
|