@blocks-network/mcp-server 0.1.55 → 0.1.62

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
3
  import { resolve, relative } from 'node:path';
4
- import { realpathSync, statSync } from 'node:fs';
4
+ import { realpathSync } 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,23 @@ 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 {
19
+ sendTask,
20
+ getTask,
21
+ listTasks,
22
+ cancelTask,
23
+ listAgents,
24
+ getAgentCard,
25
+ connectTask,
26
+ defaultFileSize,
27
+ type ToolDeps,
28
+ type TaskClientLike,
29
+ type AgentEntryLike,
30
+ } from './tools.js';
31
+
19
32
  const ALLOWED_ROOT = process.env.BLOCKS_MCP_FILE_ROOT ?? process.cwd();
20
33
 
21
34
  if (!process.env.BLOCKS_API_KEY) {
@@ -38,12 +51,6 @@ function validateFilePath(filePath: string): string {
38
51
  return real;
39
52
  }
40
53
 
41
- function resolveBillingMode(task: TaskInfo): 'free' | 'paid' {
42
- const mode = task.billingMode as string | undefined;
43
- if (mode === 'paid') return 'paid';
44
- return 'free';
45
- }
46
-
47
54
  const taskClients = new Map<string, TaskClient>();
48
55
  let resolvedBaseUrl: string | undefined;
49
56
 
@@ -58,59 +65,35 @@ async function getBaseUrl(): Promise<string> {
58
65
  return resolvedBaseUrl;
59
66
  }
60
67
 
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> {
68
+ async function getTaskClient(billingMode: 'free' | 'paid' = 'free'): Promise<TaskClientLike> {
96
69
  let client = taskClients.get(billingMode);
97
70
  if (!client) {
98
71
  const apiKey = process.env.BLOCKS_API_KEY;
99
72
  client = await TaskClient.create({ apiKey, billingMode });
100
73
  taskClients.set(billingMode, client);
101
74
  }
102
- return client;
75
+ return client as unknown as TaskClientLike;
103
76
  }
104
77
 
78
+ const deps: ToolDeps = {
79
+ getBaseUrl,
80
+ getApiKey: () => process.env.BLOCKS_API_KEY,
81
+ getTaskClient,
82
+ getAgentByName: (agentName, options) =>
83
+ getAgent(agentName, options) as Promise<AgentEntryLike | null>,
84
+ listAgents: listAgentsAuthenticated,
85
+ validateFilePath,
86
+ fileSize: defaultFileSize,
87
+ maxUploadBytes: BLOCKS_MAX_UPLOAD_BYTES,
88
+ filePartFromPath,
89
+ textPart,
90
+ };
91
+
105
92
  const server = new McpServer({
106
93
  name: 'blocks-network',
107
- version: '0.1.0',
94
+ version: '0.1.60',
108
95
  });
109
96
 
110
- // ============================================================================
111
- // Tool: send_task
112
- // ============================================================================
113
-
114
97
  server.tool(
115
98
  'send_task',
116
99
  'Send a task to a Blocks Network agent and wait for the result',
@@ -118,149 +101,27 @@ server.tool(
118
101
  agentName: z.string().describe('Name of the target agent'),
119
102
  message: z.string().describe('Text message to send to the agent'),
120
103
  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'),
104
+ inputs: z
105
+ .record(z.string(), z.string())
106
+ .optional()
107
+ .describe('Additional named inputs as {partId: value} pairs'),
122
108
  taskKind: z.enum(['request', 'pipe']).optional().describe('Task kind (default: request)'),
123
109
  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
- }
110
+ timeoutMs: z
111
+ .number()
112
+ .optional()
113
+ .describe('Timeout in milliseconds to wait for result (default: 60000)'),
210
114
  },
115
+ (params) => sendTask(params, deps),
211
116
  );
212
117
 
213
- // ============================================================================
214
- // Tool: get_task
215
- // ============================================================================
216
-
217
118
  server.tool(
218
119
  'get_task',
219
120
  '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
- },
121
+ { taskId: z.string().describe('The task ID to look up') },
122
+ (params) => getTask(params, deps),
258
123
  );
259
124
 
260
- // ============================================================================
261
- // Tool: list_tasks
262
- // ============================================================================
263
-
264
125
  server.tool(
265
126
  'list_tasks',
266
127
  'List tasks, optionally filtered by agent or state',
@@ -269,85 +130,37 @@ server.tool(
269
130
  state: z.string().optional().describe('Filter by state (e.g. completed, failed, running)'),
270
131
  limit: z.number().optional().describe('Max results to return'),
271
132
  },
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
- },
133
+ (params) => listTasks(params, deps),
281
134
  );
282
135
 
283
- // ============================================================================
284
- // Tool: cancel_task
285
- // ============================================================================
286
-
287
136
  server.tool(
288
137
  'cancel_task',
289
138
  '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
- },
139
+ { taskId: z.string().describe('The task ID to cancel') },
140
+ (params) => cancelTask(params, deps),
298
141
  );
299
142
 
300
- // ============================================================================
301
- // Tool: list_agents
302
- // ============================================================================
303
-
304
143
  server.tool(
305
144
  'list_agents',
306
145
  'List available agents in the Blocks Network registry. Use listing="private" with an API key to discover your private agents.',
307
146
  {
308
147
  skill: z.string().optional().describe('Filter by skill name'),
309
- listing: z.enum(['public', 'private']).optional().describe('Filter by listing visibility (default: public)'),
148
+ listing: z
149
+ .enum(['public', 'private'])
150
+ .optional()
151
+ .describe('Filter by listing visibility (default: public)'),
310
152
  limit: z.number().optional().describe('Max results to return'),
311
153
  },
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
- },
154
+ (params) => listAgents(params, deps),
324
155
  );
325
156
 
326
- // ============================================================================
327
- // Tool: get_agent_card
328
- // ============================================================================
329
-
330
157
  server.tool(
331
158
  'get_agent_card',
332
159
  'Get the full agent card for a specific agent',
333
- {
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) }] };
344
- },
160
+ { agentName: z.string().describe('Agent name to look up') },
161
+ (params) => getAgentCard(params, deps),
345
162
  );
346
163
 
347
- // ============================================================================
348
- // Tool: connect_task
349
- // ============================================================================
350
-
351
164
  server.tool(
352
165
  'connect_task',
353
166
  'Connect to an existing task, stream events and data until completion',
@@ -355,108 +168,9 @@ server.tool(
355
168
  taskId: z.string().describe('The task ID to connect to'),
356
169
  timeoutMs: z.number().optional().describe('Timeout in milliseconds (default: 60000)'),
357
170
  },
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
- }
435
-
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
- }
453
- },
171
+ (params) => connectTask(params, deps),
454
172
  );
455
173
 
456
- // ============================================================================
457
- // Start server
458
- // ============================================================================
459
-
460
174
  function shutdown() {
461
175
  for (const client of taskClients.values()) {
462
176
  client.destroy();
@@ -0,0 +1,58 @@
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
+ export const PROTOCOL_VERSION_HEADER = 'Blocks-Protocol-Version';
10
+ export const CURRENT_PROTOCOL_VERSION = '2026-05-01';
11
+
12
+ export interface AgentListEntry {
13
+ agentName: string;
14
+ name?: string;
15
+ description?: string;
16
+ listing?: string;
17
+ billingMode?: string;
18
+ skills?: Array<{ id: string; name: string }>;
19
+ }
20
+
21
+ export interface ListAgentsOptions {
22
+ baseUrl: string;
23
+ apiKey?: string;
24
+ skill?: string;
25
+ listing?: 'public' | 'private';
26
+ limit?: number;
27
+ fetchImpl?: typeof fetch;
28
+ }
29
+
30
+ export interface ListAgentsResult {
31
+ agents: AgentListEntry[];
32
+ totalCount?: number;
33
+ }
34
+
35
+ export async function listAgentsAuthenticated(
36
+ opts: ListAgentsOptions,
37
+ ): Promise<ListAgentsResult> {
38
+ const params = new URLSearchParams({ include: 'full' });
39
+ if (opts.skill) params.set('skill', opts.skill);
40
+ if (opts.listing) {
41
+ params.set('listing', opts.listing);
42
+ if (opts.listing === 'private') params.set('scope', 'owned');
43
+ }
44
+ if (opts.limit) params.set('limit', String(opts.limit));
45
+
46
+ const url = `${opts.baseUrl.replace(/\/+$/, '')}/api/v1/registry/agents?${params}`;
47
+ const headers: Record<string, string> = {
48
+ [PROTOCOL_VERSION_HEADER]: CURRENT_PROTOCOL_VERSION,
49
+ };
50
+ if (opts.apiKey) headers['Authorization'] = `Bearer ${opts.apiKey}`;
51
+
52
+ const fetchFn = opts.fetchImpl ?? fetch;
53
+ const response = await fetchFn(url, { headers });
54
+ if (!response.ok) {
55
+ throw new Error(`Registry list failed: HTTP ${response.status}`);
56
+ }
57
+ return (await response.json()) as ListAgentsResult;
58
+ }