@blocks-network/mcp-server 0.1.55 → 0.1.64

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { resolve, relative } from 'node:path';
4
- import { realpathSync, statSync } from 'node:fs';
3
+ import { resolve, relative, dirname } from 'node:path';
4
+ import { realpathSync, mkdirSync, writeFileSync } from 'node:fs';
5
5
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
6
6
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
7
7
  import { z } from 'zod';
@@ -12,10 +12,32 @@ import {
12
12
  fetchCdmConfig,
13
13
  getAgent,
14
14
  BLOCKS_MAX_UPLOAD_BYTES,
15
- type TaskInfo,
16
- type SendMessageRequestPart,
17
15
  } from '@blocks-network/sdk';
18
16
 
17
+ import { listAgentsAuthenticated } from './registry-list.js';
18
+ import { fetchAgentStatus, MAX_AGENT_NAMES } from './agent-status.js';
19
+ import { getConsumerBalance, createConsumerTopUp } from './billing.js';
20
+ import {
21
+ sendTask,
22
+ getTask,
23
+ listTasks,
24
+ cancelTask,
25
+ pauseTask,
26
+ resumeTask,
27
+ retryTask,
28
+ listAgents,
29
+ getAgentCard,
30
+ getAgentStatus,
31
+ connectTask,
32
+ downloadArtifact,
33
+ checkBalance,
34
+ requestTopup,
35
+ defaultFileSize,
36
+ type ToolDeps,
37
+ type TaskClientLike,
38
+ type AgentEntryLike,
39
+ } from './tools.js';
40
+
19
41
  const ALLOWED_ROOT = process.env.BLOCKS_MCP_FILE_ROOT ?? process.cwd();
20
42
 
21
43
  if (!process.env.BLOCKS_API_KEY) {
@@ -38,10 +60,19 @@ function validateFilePath(filePath: string): string {
38
60
  return real;
39
61
  }
40
62
 
41
- function resolveBillingMode(task: TaskInfo): 'free' | 'paid' {
42
- const mode = task.billingMode as string | undefined;
43
- if (mode === 'paid') return 'paid';
44
- return 'free';
63
+ function resolveSavePath(filePath: string): string {
64
+ const root = realpathSync(resolve(ALLOWED_ROOT));
65
+ const resolved = resolve(root, filePath);
66
+ const rel = relative(root, resolved);
67
+ if (rel.startsWith('..') || resolve(root, rel) !== resolved) {
68
+ throw new Error(`Save path must be within ${ALLOWED_ROOT}`);
69
+ }
70
+ mkdirSync(dirname(resolved), { recursive: true });
71
+ return resolved;
72
+ }
73
+
74
+ function writeFile(filePath: string, data: Uint8Array): void {
75
+ writeFileSync(filePath, data);
45
76
  }
46
77
 
47
78
  const taskClients = new Map<string, TaskClient>();
@@ -58,59 +89,41 @@ async function getBaseUrl(): Promise<string> {
58
89
  return resolvedBaseUrl;
59
90
  }
60
91
 
61
- interface AgentListEntry {
62
- agentName: string;
63
- name?: string;
64
- description?: string;
65
- listing?: string;
66
- billingMode?: string;
67
- skills?: Array<{ id: string; name: string }>;
68
- }
69
-
70
- async function listAgentsAuthenticated(opts: {
71
- baseUrl: string;
72
- apiKey?: string;
73
- skill?: string;
74
- listing?: 'public' | 'private';
75
- limit?: number;
76
- }): Promise<{ agents: AgentListEntry[]; totalCount?: number }> {
77
- const params = new URLSearchParams({ include: 'full' });
78
- if (opts.skill) params.set('skill', opts.skill);
79
- if (opts.listing) {
80
- params.set('listing', opts.listing);
81
- if (opts.listing === 'private') params.set('scope', 'owned');
82
- }
83
- if (opts.limit) params.set('limit', String(opts.limit));
84
- const url = `${opts.baseUrl.replace(/\/+$/, '')}/api/v1/registry/agents?${params}`;
85
- const headers: Record<string, string> = {};
86
- if (opts.apiKey) headers['Authorization'] = `Bearer ${opts.apiKey}`;
87
- const response = await fetch(url, { headers });
88
- if (!response.ok) {
89
- throw new Error(`Registry list failed: HTTP ${response.status}`);
90
- }
91
- const data = await response.json() as { agents: AgentListEntry[]; totalCount?: number };
92
- return data;
93
- }
94
-
95
- async function getTaskClient(billingMode: 'free' | 'paid' = 'free'): Promise<TaskClient> {
92
+ async function getTaskClient(billingMode: 'free' | 'paid' = 'free'): Promise<TaskClientLike> {
96
93
  let client = taskClients.get(billingMode);
97
94
  if (!client) {
98
95
  const apiKey = process.env.BLOCKS_API_KEY;
99
96
  client = await TaskClient.create({ apiKey, billingMode });
100
97
  taskClients.set(billingMode, client);
101
98
  }
102
- return client;
99
+ return client as unknown as TaskClientLike;
103
100
  }
104
101
 
102
+ const deps: ToolDeps = {
103
+ getBaseUrl,
104
+ getApiKey: () => process.env.BLOCKS_API_KEY,
105
+ getOrgId: () => process.env.BLOCKS_ORG_ID,
106
+ getTaskClient,
107
+ getAgentByName: (agentName, options) =>
108
+ getAgent(agentName, options) as Promise<AgentEntryLike | null>,
109
+ listAgents: listAgentsAuthenticated,
110
+ fetchAgentStatus,
111
+ getConsumerBalance,
112
+ createConsumerTopUp,
113
+ validateFilePath,
114
+ resolveSavePath,
115
+ writeFile,
116
+ fileSize: defaultFileSize,
117
+ maxUploadBytes: BLOCKS_MAX_UPLOAD_BYTES,
118
+ filePartFromPath,
119
+ textPart,
120
+ };
121
+
105
122
  const server = new McpServer({
106
123
  name: 'blocks-network',
107
- version: '0.1.0',
124
+ version: '0.1.60',
108
125
  });
109
126
 
110
- // ============================================================================
111
- // Tool: send_task
112
- // ============================================================================
113
-
114
127
  server.tool(
115
128
  'send_task',
116
129
  'Send a task to a Blocks Network agent and wait for the result',
@@ -118,149 +131,27 @@ server.tool(
118
131
  agentName: z.string().describe('Name of the target agent'),
119
132
  message: z.string().describe('Text message to send to the agent'),
120
133
  filePath: z.string().optional().describe('Optional file path to attach'),
121
- inputs: z.record(z.string(), z.string()).optional().describe('Additional named inputs as {partId: value} pairs'),
134
+ inputs: z
135
+ .record(z.string(), z.string())
136
+ .optional()
137
+ .describe('Additional named inputs as {partId: value} pairs'),
122
138
  taskKind: z.enum(['request', 'pipe']).optional().describe('Task kind (default: request)'),
123
139
  duration: z.number().optional().describe('Duration in minutes (required for pipe tasks)'),
124
- timeoutMs: z.number().optional().describe('Timeout in milliseconds to wait for result (default: 60000)'),
125
- },
126
- async ({ agentName, message, filePath, inputs, taskKind, duration, timeoutMs }) => {
127
- const baseUrl = await getBaseUrl();
128
- const apiKey = process.env.BLOCKS_API_KEY;
129
- const entry = await getAgent(agentName, { baseUrl, apiKey });
130
- const billingMode = entry?.billingMode ?? 'free';
131
- const client = await getTaskClient(billingMode);
132
- const declaredInputs = entry?.card?.io?.inputs ?? [];
133
- const isTextLike = (ct: string) =>
134
- ct.startsWith('text/') || ct === 'application/json' || ct.endsWith('+json');
135
- const textInput = declaredInputs.find((i) => isTextLike(i.contentType));
136
- const fileInput = declaredInputs.find((i) => !isTextLike(i.contentType));
137
-
138
- const requestParts: SendMessageRequestPart[] = [];
139
- const textPartId = textInput?.id ?? 'text';
140
- if ((textInput || declaredInputs.length === 0) && !inputs?.[textPartId]) {
141
- requestParts.push(textPart(message, textPartId));
142
- }
143
- if (filePath) {
144
- const safePath = validateFilePath(filePath);
145
- const fileSize = statSync(safePath).size;
146
- if (fileSize > BLOCKS_MAX_UPLOAD_BYTES) {
147
- return {
148
- content: [{ type: 'text', text: `File too large (${fileSize} bytes). Maximum upload size is ${BLOCKS_MAX_UPLOAD_BYTES} bytes (25 MB).` }],
149
- isError: true,
150
- };
151
- }
152
- requestParts.push(await filePartFromPath(safePath, {
153
- partId: fileInput?.id ?? 'file',
154
- contentType: fileInput?.contentType,
155
- }));
156
- }
157
- if (inputs) {
158
- for (const [partId, value] of Object.entries(inputs)) {
159
- requestParts.push(textPart(value, partId));
160
- }
161
- }
162
-
163
- const session = await client.sendMessage({
164
- agentName,
165
- requestParts,
166
- taskKind,
167
- duration,
168
- });
169
-
170
- const timeout = timeoutMs ?? 60000;
171
- const results: string[] = [];
172
-
173
- session.onProgress((event) => {
174
- if (event.message) {
175
- results.push(`[progress] ${event.message}`);
176
- }
177
- });
178
-
179
- try {
180
- const terminal = await session.waitForTerminal(timeout);
181
- const allArtifacts = session.listArtifacts();
182
- const output = [`Task ${session.taskId} ${terminal.state}`, ...results];
183
-
184
- for (const ref of allArtifacts) {
185
- try {
186
- const downloaded = await session.downloadArtifact(ref);
187
- const isText = downloaded.mimeType.startsWith('text/') ||
188
- downloaded.mimeType === 'application/json';
189
- if (isText) {
190
- const text = new TextDecoder().decode(downloaded.data);
191
- output.push(`[artifact: ${downloaded.fileName ?? 'unnamed'}]\n${text}`);
192
- } else {
193
- output.push(`[artifact: ${downloaded.fileName ?? 'unnamed'}] (${downloaded.mimeType}, ${downloaded.data.length} bytes)`);
194
- }
195
- } catch {
196
- output.push(`[artifact: ${ref.fileName ?? 'unnamed'}] (download failed)`);
197
- }
198
- }
199
-
200
- if (terminal.state === 'failed') {
201
- output.push(`Error: ${terminal.error ?? terminal.reason ?? 'unknown'}`);
202
- }
203
- session.close();
204
- return { content: [{ type: 'text', text: output.join('\n') }] };
205
- } catch (err) {
206
- session.close();
207
- const msg = err instanceof Error ? err.message : String(err);
208
- return { content: [{ type: 'text', text: `Task ${session.taskId} error: ${msg}` }], isError: true };
209
- }
140
+ timeoutMs: z
141
+ .number()
142
+ .optional()
143
+ .describe('Timeout in milliseconds to wait for result (default: 60000)'),
210
144
  },
145
+ (params) => sendTask(params, deps),
211
146
  );
212
147
 
213
- // ============================================================================
214
- // Tool: get_task
215
- // ============================================================================
216
-
217
148
  server.tool(
218
149
  'get_task',
219
150
  'Get the current status of a task by ID, including artifact content if completed',
220
- {
221
- taskId: z.string().describe('The task ID to look up'),
222
- },
223
- async ({ taskId }) => {
224
- const freeClient = await getTaskClient('free');
225
- const task = await freeClient.getTask(taskId);
226
- const output = [JSON.stringify(task, null, 2)];
227
-
228
- const terminalStates = new Set(['completed', 'failed', 'canceled']);
229
- if (task.state && terminalStates.has(task.state)) {
230
- try {
231
- const billingMode = resolveBillingMode(task);
232
- const client = await getTaskClient(billingMode);
233
- const session = await client.connect({ taskId });
234
- const allArtifacts = session.listArtifacts();
235
- for (const ref of allArtifacts) {
236
- try {
237
- const downloaded = await session.downloadArtifact(ref);
238
- const isText = downloaded.mimeType.startsWith('text/') ||
239
- downloaded.mimeType === 'application/json';
240
- if (isText) {
241
- const text = new TextDecoder().decode(downloaded.data);
242
- output.push(`[artifact: ${downloaded.fileName ?? 'unnamed'}]\n${text}`);
243
- } else {
244
- output.push(`[artifact: ${downloaded.fileName ?? 'unnamed'}] (${downloaded.mimeType}, ${downloaded.data.length} bytes)`);
245
- }
246
- } catch {
247
- output.push(`[artifact: ${ref.fileName ?? 'unnamed'}] (download failed)`);
248
- }
249
- }
250
- session.close();
251
- } catch {
252
- // connect failed — return task info without artifacts
253
- }
254
- }
255
-
256
- return { content: [{ type: 'text', text: output.join('\n') }] };
257
- },
151
+ { taskId: z.string().describe('The task ID to look up') },
152
+ (params) => getTask(params, deps),
258
153
  );
259
154
 
260
- // ============================================================================
261
- // Tool: list_tasks
262
- // ============================================================================
263
-
264
155
  server.tool(
265
156
  'list_tasks',
266
157
  'List tasks, optionally filtered by agent or state',
@@ -269,84 +160,91 @@ server.tool(
269
160
  state: z.string().optional().describe('Filter by state (e.g. completed, failed, running)'),
270
161
  limit: z.number().optional().describe('Max results to return'),
271
162
  },
272
- async ({ agentName, state, limit }) => {
273
- const client = await getTaskClient();
274
- const result = await client.listTasks({ agentName, state, limit });
275
- const lines = result.tasks.map((t: TaskInfo) =>
276
- `${t.taskId} | ${t.agentName ?? '?'} | ${t.state ?? '?'} | ${t.createdTime ?? ''}`,
277
- );
278
- const header = `Tasks (${result.totalCount ?? result.tasks.length} total):`;
279
- return { content: [{ type: 'text', text: [header, ...lines].join('\n') }] };
280
- },
163
+ (params) => listTasks(params, deps),
281
164
  );
282
165
 
283
- // ============================================================================
284
- // Tool: cancel_task
285
- // ============================================================================
286
-
287
166
  server.tool(
288
167
  'cancel_task',
289
168
  'Cancel a running task',
290
- {
291
- taskId: z.string().describe('The task ID to cancel'),
292
- },
293
- async ({ taskId }) => {
294
- const client = await getTaskClient();
295
- await client.cancelTask(taskId);
296
- return { content: [{ type: 'text', text: `Task ${taskId} cancelled.` }] };
297
- },
169
+ { taskId: z.string().describe('The task ID to cancel') },
170
+ (params) => cancelTask(params, deps),
298
171
  );
299
172
 
300
- // ============================================================================
301
- // Tool: list_agents
302
- // ============================================================================
173
+ server.tool(
174
+ 'pause_task',
175
+ 'Pause a running pipe task. Resume later with resume_task.',
176
+ { taskId: z.string().describe('The task ID to pause') },
177
+ (params) => pauseTask(params, deps),
178
+ );
179
+
180
+ server.tool(
181
+ 'resume_task',
182
+ 'Resume a paused pipe task',
183
+ { taskId: z.string().describe('The task ID to resume') },
184
+ (params) => resumeTask(params, deps),
185
+ );
186
+
187
+ server.tool(
188
+ 'retry_task',
189
+ 'Retry a failed task',
190
+ { taskId: z.string().describe('The task ID to retry') },
191
+ (params) => retryTask(params, deps),
192
+ );
303
193
 
304
194
  server.tool(
305
195
  'list_agents',
306
196
  'List available agents in the Blocks Network registry. Use listing="private" with an API key to discover your private agents.',
307
197
  {
308
- skill: z.string().optional().describe('Filter by skill name'),
309
- listing: z.enum(['public', 'private']).optional().describe('Filter by listing visibility (default: public)'),
198
+ tag: z.string().optional().describe('Filter by tag slug'),
199
+ listing: z
200
+ .enum(['public', 'private'])
201
+ .optional()
202
+ .describe('Filter by listing visibility (default: public)'),
310
203
  limit: z.number().optional().describe('Max results to return'),
311
204
  },
312
- async ({ skill, listing, limit }) => {
313
- const baseUrl = await getBaseUrl();
314
- const apiKey = process.env.BLOCKS_API_KEY;
315
- const result = await listAgentsAuthenticated({ baseUrl, apiKey, skill, listing, limit });
316
-
317
- const lines = result.agents.map((a) => {
318
- const skills = a.skills?.map((s) => s.name).join(', ') ?? '';
319
- return `${a.agentName} | ${a.name ?? a.agentName} | ${a.listing ?? 'public'} | ${skills}`;
320
- });
321
- const header = `Agents (${result.totalCount ?? result.agents.length}):`;
322
- return { content: [{ type: 'text', text: [header, ...lines].join('\n') }] };
323
- },
205
+ (params) => listAgents(params, deps),
324
206
  );
325
207
 
326
- // ============================================================================
327
- // Tool: get_agent_card
328
- // ============================================================================
329
-
330
208
  server.tool(
331
209
  'get_agent_card',
332
210
  'Get the full agent card for a specific agent',
211
+ { agentName: z.string().describe('Agent name to look up') },
212
+ (params) => getAgentCard(params, deps),
213
+ );
214
+
215
+ server.tool(
216
+ 'get_agent_status',
217
+ '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.)',
333
218
  {
334
- agentName: z.string().describe('Agent name to look up'),
335
- },
336
- async ({ agentName }) => {
337
- const baseUrl = await getBaseUrl();
338
- const apiKey = process.env.BLOCKS_API_KEY;
339
- const entry = await getAgent(agentName, { baseUrl, apiKey });
340
- if (!entry) {
341
- return { content: [{ type: 'text', text: `Agent "${agentName}" not found.` }], isError: true };
342
- }
343
- return { content: [{ type: 'text', text: JSON.stringify(entry.card ?? entry, null, 2) }] };
219
+ agentNames: z
220
+ .array(z.string())
221
+ .min(1)
222
+ .max(MAX_AGENT_NAMES)
223
+ .describe(`Agent names to check (1-${MAX_AGENT_NAMES})`),
344
224
  },
225
+ (params) => getAgentStatus(params, deps),
345
226
  );
346
227
 
347
- // ============================================================================
348
- // Tool: connect_task
349
- // ============================================================================
228
+ server.tool(
229
+ 'check_balance',
230
+ 'Get the consumer billing balance for the configured org (BLOCKS_ORG_ID): ledger balance, active reservations, and available balance.',
231
+ {},
232
+ (_params) => checkBalance({}, deps),
233
+ );
234
+
235
+ server.tool(
236
+ 'request_topup',
237
+ 'Create a Stripe Checkout session to add USD to the consumer balance. Returns a URL the user opens in a browser to complete payment; the MCP server does not handle payment itself. Minimum top-up is $5 (platform `MIN_BILLING_AMOUNT`).',
238
+ {
239
+ amountUsd: z
240
+ .number()
241
+ .positive()
242
+ .describe(
243
+ 'Amount to add in USD (e.g. 25 or 19.99). Whole-cent precision. Must be at least $5 (platform minimum).',
244
+ ),
245
+ },
246
+ (params) => requestTopup(params, deps),
247
+ );
350
248
 
351
249
  server.tool(
352
250
  'connect_task',
@@ -355,108 +253,23 @@ server.tool(
355
253
  taskId: z.string().describe('The task ID to connect to'),
356
254
  timeoutMs: z.number().optional().describe('Timeout in milliseconds (default: 60000)'),
357
255
  },
358
- async ({ taskId, timeoutMs }) => {
359
- const freeClient = await getTaskClient('free');
360
- const task = await freeClient.getTask(taskId);
361
- const billingMode = resolveBillingMode(task);
362
- const client = await getTaskClient(billingMode);
363
- const session = await client.connect({ taskId });
364
-
365
- const timeout = timeoutMs ?? 60000;
366
- const results: string[] = [];
367
- const streamOutputs: Array<{ label: string; chunks: string[] }> = [];
368
- const streamDrains: Promise<void>[] = [];
369
-
370
- session.onProgress((event) => {
371
- if (event.message) {
372
- results.push(`[progress] ${event.message}`);
373
- }
374
- });
375
-
376
- const drainStream = (streamRef: { descriptor: { localDirection: string; format: string; declaredStream?: string; streamId: string }; open: () => { events: () => AsyncIterable<unknown>; bytes: () => AsyncIterable<Uint8Array> } }) => {
377
- const dir = streamRef.descriptor.localDirection;
378
- if (dir !== 'inbound' && dir !== 'bidirectional') return;
379
- const format = streamRef.descriptor.format;
380
- const label = streamRef.descriptor.declaredStream ?? streamRef.descriptor.streamId;
381
- const entry = { label, chunks: [] as string[] };
382
- streamOutputs.push(entry);
383
- try {
384
- const streamClient = streamRef.open();
385
- const drain = (async () => {
386
- if (format === 'events') {
387
- for await (const event of streamClient.events()) {
388
- entry.chunks.push(JSON.stringify(event) + '\n');
389
- }
390
- } else {
391
- const decoder = new TextDecoder();
392
- for await (const chunk of streamClient.bytes()) {
393
- entry.chunks.push(decoder.decode(chunk, { stream: true }));
394
- }
395
- }
396
- })();
397
- streamDrains.push(drain.catch(() => {}));
398
- } catch {
399
- // stream already ended or terminal
400
- }
401
- };
402
-
403
- for (const ref of session.listStreams()) {
404
- drainStream(ref);
405
- }
406
- session.onStream(drainStream);
407
-
408
- try {
409
- const terminal = await session.waitForTerminal(timeout);
410
- await Promise.allSettled(streamDrains);
411
- const allArtifacts = session.listArtifacts();
412
- results.unshift(`Task ${taskId} ${terminal.state}`);
413
-
414
- for (const s of streamOutputs) {
415
- if (s.chunks.length > 0) {
416
- results.push(`[stream: ${s.label}]\n${s.chunks.join('')}`);
417
- }
418
- }
419
-
420
- for (const ref of allArtifacts) {
421
- try {
422
- const downloaded = await session.downloadArtifact(ref);
423
- const isText = downloaded.mimeType.startsWith('text/') ||
424
- downloaded.mimeType === 'application/json';
425
- if (isText) {
426
- const text = new TextDecoder().decode(downloaded.data);
427
- results.push(`[artifact: ${downloaded.fileName ?? 'unnamed'}]\n${text}`);
428
- } else {
429
- results.push(`[artifact: ${downloaded.fileName ?? 'unnamed'}] (${downloaded.mimeType}, ${downloaded.data.length} bytes)`);
430
- }
431
- } catch {
432
- results.push(`[artifact: ${ref.fileName ?? 'unnamed'}] (download failed)`);
433
- }
434
- }
256
+ (params) => connectTask(params, deps),
257
+ );
435
258
 
436
- if (terminal.state === 'failed') {
437
- results.push(`Error: ${terminal.error ?? terminal.reason ?? 'unknown'}`);
438
- }
439
- await session.asyncClose();
440
- return { content: [{ type: 'text', text: results.join('\n') }] };
441
- } catch (err) {
442
- await session.asyncClose();
443
- const msg = err instanceof Error ? err.message : String(err);
444
- const partial: string[] = [`Task ${taskId} timed out (${msg})`];
445
- for (const s of streamOutputs) {
446
- if (s.chunks.length > 0) {
447
- partial.push(`[stream: ${s.label}]\n${s.chunks.join('')}`);
448
- }
449
- }
450
- partial.push(...results);
451
- return { content: [{ type: 'text', text: partial.join('\n') }], isError: true };
452
- }
259
+ server.tool(
260
+ 'download_artifact',
261
+ 'Download a single artifact from a task by file name. If savePath is provided, writes the artifact to disk under BLOCKS_MCP_FILE_ROOT; otherwise returns the content inline (text decoded, binary base64-encoded).',
262
+ {
263
+ taskId: z.string().describe('The task ID that produced the artifact'),
264
+ fileName: z.string().describe('Artifact file name as listed by get_task'),
265
+ savePath: z
266
+ .string()
267
+ .optional()
268
+ .describe('Optional path (relative to BLOCKS_MCP_FILE_ROOT) to write the artifact to'),
453
269
  },
270
+ (params) => downloadArtifact(params, deps),
454
271
  );
455
272
 
456
- // ============================================================================
457
- // Start server
458
- // ============================================================================
459
-
460
273
  function shutdown() {
461
274
  for (const client of taskClients.values()) {
462
275
  client.destroy();
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Shared protocol-version header constants for direct-HTTP backend
3
+ * callers (`registry-list`, `agent-status`, `billing`).
4
+ *
5
+ * MUST stay in sync with
6
+ * blocks-sdk/sdks/node/src/runtime/protocol-version.ts (the canonical
7
+ * source). Until the SDK exports these from its public surface, both
8
+ * files must update atomically when the protocol version bumps.
9
+ */
10
+
11
+ export const PROTOCOL_VERSION_HEADER = 'Blocks-Protocol-Version';
12
+ export const CURRENT_PROTOCOL_VERSION = '2026-05-01';
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Authenticated registry list helper.
3
+ *
4
+ * Calls GET /api/v1/registry/agents with the Blocks-Protocol-Version
5
+ * header (required by the backend protocol-version middleware) and an
6
+ * optional Bearer API key for private/owned listings.
7
+ */
8
+
9
+ import {
10
+ PROTOCOL_VERSION_HEADER,
11
+ CURRENT_PROTOCOL_VERSION,
12
+ } from './protocol-headers.js';
13
+
14
+ export interface AgentListEntry {
15
+ agentName: string;
16
+ name?: string;
17
+ description?: string;
18
+ listing?: string;
19
+ billingMode?: string;
20
+ tags?: Array<{ id: string; name: string }>;
21
+ }
22
+
23
+ export interface ListAgentsOptions {
24
+ baseUrl: string;
25
+ apiKey?: string;
26
+ tag?: string;
27
+ listing?: 'public' | 'private';
28
+ limit?: number;
29
+ fetchImpl?: typeof fetch;
30
+ }
31
+
32
+ export interface ListAgentsResult {
33
+ agents: AgentListEntry[];
34
+ totalCount?: number;
35
+ }
36
+
37
+ export async function listAgentsAuthenticated(
38
+ opts: ListAgentsOptions,
39
+ ): Promise<ListAgentsResult> {
40
+ const params = new URLSearchParams({ include: 'full' });
41
+ if (opts.tag) params.set('tag', opts.tag);
42
+ if (opts.listing) {
43
+ params.set('listing', opts.listing);
44
+ if (opts.listing === 'private') params.set('scope', 'owned');
45
+ }
46
+ if (opts.limit) params.set('limit', String(opts.limit));
47
+
48
+ const url = `${opts.baseUrl.replace(/\/+$/, '')}/api/v1/registry/agents?${params}`;
49
+ const headers: Record<string, string> = {
50
+ [PROTOCOL_VERSION_HEADER]: CURRENT_PROTOCOL_VERSION,
51
+ };
52
+ if (opts.apiKey) headers['Authorization'] = `Bearer ${opts.apiKey}`;
53
+
54
+ const fetchFn = opts.fetchImpl ?? fetch;
55
+ const response = await fetchFn(url, { headers });
56
+ if (!response.ok) {
57
+ throw new Error(`Registry list failed: HTTP ${response.status}`);
58
+ }
59
+ return (await response.json()) as ListAgentsResult;
60
+ }