@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/README.md +18 -4
- package/dist/agent-status.d.ts +49 -0
- package/dist/agent-status.js +40 -0
- package/dist/billing.d.ts +50 -0
- package/dist/billing.js +72 -0
- package/dist/index.js +82 -306
- package/dist/protocol-headers.d.ts +11 -0
- package/dist/protocol-headers.js +11 -0
- package/dist/registry-list.d.ts +31 -0
- package/dist/registry-list.js +32 -0
- package/dist/tools.d.ts +197 -0
- package/dist/tools.js +441 -0
- package/package.json +18 -6
- package/src/agent-status.ts +89 -0
- package/src/billing.ts +122 -0
- package/src/index.ts +148 -335
- package/src/protocol-headers.ts +12 -0
- package/src/registry-list.ts +60 -0
- package/src/tools.ts +739 -0
- package/tests/billing.test.ts +203 -0
- package/tests/cancel-task.test.ts +24 -0
- package/tests/connect-task.test.ts +180 -0
- package/tests/download-artifact.test.ts +142 -0
- package/tests/get-agent-card.test.ts +53 -0
- package/tests/get-agent-status.test.ts +108 -0
- package/tests/get-task.test.ts +76 -0
- package/tests/helpers.ts +250 -0
- package/tests/list-agents.test.ts +74 -0
- package/tests/list-tasks.test.ts +58 -0
- package/tests/registry-list.test.ts +120 -0
- package/tests/send-task.test.ts +221 -0
- package/tests/task-lifecycle.test.ts +64 -0
- package/vitest.config.ts +7 -0
package/src/tools.ts
ADDED
|
@@ -0,0 +1,739 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool handlers for the Blocks Network MCP server.
|
|
3
|
+
*
|
|
4
|
+
* Each handler is a pure function that takes its dependencies (clients,
|
|
5
|
+
* registry helpers, fs/path utilities) so it can be unit-tested without
|
|
6
|
+
* touching the network or the filesystem.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { statSync } from 'node:fs';
|
|
10
|
+
import {
|
|
11
|
+
textPart,
|
|
12
|
+
filePartFromPath,
|
|
13
|
+
type TaskInfo,
|
|
14
|
+
type SendMessageRequestPart,
|
|
15
|
+
} from '@blocks-network/sdk';
|
|
16
|
+
|
|
17
|
+
import type { ListAgentsResult } from './registry-list.js';
|
|
18
|
+
import type { AgentStatusResponse } from './agent-status.js';
|
|
19
|
+
import type { ConsumerBalance, TopUpSession } from './billing.js';
|
|
20
|
+
|
|
21
|
+
// ============================================================================
|
|
22
|
+
// Types — minimal local shapes mirroring @blocks-network/sdk surfaces.
|
|
23
|
+
// Using narrow interfaces (instead of importing the SDK classes) keeps
|
|
24
|
+
// the tests free of PubNub/runtime dependencies.
|
|
25
|
+
// ============================================================================
|
|
26
|
+
|
|
27
|
+
export interface ArtifactRef {
|
|
28
|
+
fileName?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface DownloadedArtifact {
|
|
32
|
+
fileName?: string;
|
|
33
|
+
mimeType: string;
|
|
34
|
+
data: Uint8Array;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface StreamDescriptor {
|
|
38
|
+
localDirection: string;
|
|
39
|
+
format: string;
|
|
40
|
+
declaredStream?: string;
|
|
41
|
+
streamId: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface StreamClient {
|
|
45
|
+
events(): AsyncIterable<unknown>;
|
|
46
|
+
bytes(): AsyncIterable<Uint8Array>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface StreamRef {
|
|
50
|
+
descriptor: StreamDescriptor;
|
|
51
|
+
open(): StreamClient;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface ProgressEvent {
|
|
55
|
+
message?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface TerminalEvent {
|
|
59
|
+
state: string;
|
|
60
|
+
error?: string;
|
|
61
|
+
reason?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface TaskSessionLike {
|
|
65
|
+
taskId: string;
|
|
66
|
+
onProgress(cb: (event: ProgressEvent) => void): unknown;
|
|
67
|
+
onStream(cb: (ref: StreamRef) => void): unknown;
|
|
68
|
+
listArtifacts(): ArtifactRef[];
|
|
69
|
+
listStreams(): StreamRef[];
|
|
70
|
+
downloadArtifact(ref: ArtifactRef): Promise<DownloadedArtifact>;
|
|
71
|
+
waitForTerminal(timeoutMs?: number): Promise<TerminalEvent>;
|
|
72
|
+
close(): void;
|
|
73
|
+
asyncClose(): Promise<void>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface TaskClientLike {
|
|
77
|
+
sendMessage(params: {
|
|
78
|
+
agentName: string;
|
|
79
|
+
requestParts: SendMessageRequestPart[];
|
|
80
|
+
taskKind?: 'request' | 'pipe';
|
|
81
|
+
duration?: number;
|
|
82
|
+
}): Promise<TaskSessionLike>;
|
|
83
|
+
getTask(taskId: string): Promise<TaskInfo>;
|
|
84
|
+
listTasks(params: {
|
|
85
|
+
agentName?: string;
|
|
86
|
+
state?: string;
|
|
87
|
+
limit?: number;
|
|
88
|
+
}): Promise<{ tasks: TaskInfo[]; totalCount?: number }>;
|
|
89
|
+
cancelTask(taskId: string): Promise<unknown>;
|
|
90
|
+
pauseTask(taskId: string): Promise<unknown>;
|
|
91
|
+
resumeTask(taskId: string): Promise<unknown>;
|
|
92
|
+
retryTask(taskId: string): Promise<unknown>;
|
|
93
|
+
connect(params: { taskId: string }): Promise<TaskSessionLike>;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface AgentEntryLike {
|
|
97
|
+
agentName: string;
|
|
98
|
+
card?: {
|
|
99
|
+
io?: {
|
|
100
|
+
inputs?: Array<{ id: string; contentType: string }>;
|
|
101
|
+
};
|
|
102
|
+
};
|
|
103
|
+
billingMode?: 'free' | 'paid';
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface ToolResult {
|
|
107
|
+
[k: string]: unknown;
|
|
108
|
+
content: Array<{ type: 'text'; text: string }>;
|
|
109
|
+
isError?: boolean;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface ToolDeps {
|
|
113
|
+
getBaseUrl(): Promise<string>;
|
|
114
|
+
getApiKey(): string | undefined;
|
|
115
|
+
getTaskClient(billingMode?: 'free' | 'paid'): Promise<TaskClientLike>;
|
|
116
|
+
getAgentByName(
|
|
117
|
+
agentName: string,
|
|
118
|
+
options: { baseUrl: string; apiKey?: string },
|
|
119
|
+
): Promise<AgentEntryLike | null>;
|
|
120
|
+
listAgents(options: {
|
|
121
|
+
baseUrl: string;
|
|
122
|
+
apiKey?: string;
|
|
123
|
+
tag?: string;
|
|
124
|
+
listing?: 'public' | 'private';
|
|
125
|
+
limit?: number;
|
|
126
|
+
}): Promise<ListAgentsResult>;
|
|
127
|
+
validateFilePath(filePath: string): string;
|
|
128
|
+
resolveSavePath(filePath: string): string;
|
|
129
|
+
writeFile(filePath: string, data: Uint8Array): void;
|
|
130
|
+
fileSize(path: string): number;
|
|
131
|
+
maxUploadBytes: number;
|
|
132
|
+
filePartFromPath: typeof filePartFromPath;
|
|
133
|
+
textPart: typeof textPart;
|
|
134
|
+
getOrgId(): string | undefined;
|
|
135
|
+
fetchAgentStatus(options: {
|
|
136
|
+
baseUrl: string;
|
|
137
|
+
apiKey?: string;
|
|
138
|
+
agentNames: string[];
|
|
139
|
+
}): Promise<AgentStatusResponse>;
|
|
140
|
+
getConsumerBalance(options: {
|
|
141
|
+
baseUrl: string;
|
|
142
|
+
apiKey: string;
|
|
143
|
+
orgId: string;
|
|
144
|
+
}): Promise<ConsumerBalance>;
|
|
145
|
+
createConsumerTopUp(options: {
|
|
146
|
+
baseUrl: string;
|
|
147
|
+
apiKey: string;
|
|
148
|
+
orgId: string;
|
|
149
|
+
amountUsd: number;
|
|
150
|
+
}): Promise<TopUpSession>;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ============================================================================
|
|
154
|
+
// Helpers
|
|
155
|
+
// ============================================================================
|
|
156
|
+
|
|
157
|
+
const TERMINAL_STATES = new Set(['completed', 'failed', 'canceled']);
|
|
158
|
+
|
|
159
|
+
function resolveBillingMode(task: TaskInfo): 'free' | 'paid' {
|
|
160
|
+
const mode = task.billingMode as string | undefined;
|
|
161
|
+
return mode === 'paid' ? 'paid' : 'free';
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function isTextLikeContentType(ct: string): boolean {
|
|
165
|
+
return ct.startsWith('text/') || ct === 'application/json' || ct.endsWith('+json');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function isTextMimeType(mimeType: string): boolean {
|
|
169
|
+
return mimeType.startsWith('text/') || mimeType === 'application/json';
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function appendArtifacts(
|
|
173
|
+
session: TaskSessionLike,
|
|
174
|
+
refs: ArtifactRef[],
|
|
175
|
+
out: string[],
|
|
176
|
+
): Promise<void> {
|
|
177
|
+
for (const ref of refs) {
|
|
178
|
+
try {
|
|
179
|
+
const downloaded = await session.downloadArtifact(ref);
|
|
180
|
+
if (isTextMimeType(downloaded.mimeType)) {
|
|
181
|
+
const text = new TextDecoder().decode(downloaded.data);
|
|
182
|
+
out.push(`[artifact: ${downloaded.fileName ?? 'unnamed'}]\n${text}`);
|
|
183
|
+
} else {
|
|
184
|
+
out.push(
|
|
185
|
+
`[artifact: ${downloaded.fileName ?? 'unnamed'}] (${downloaded.mimeType}, ${downloaded.data.length} bytes)`,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
} catch {
|
|
189
|
+
out.push(`[artifact: ${ref.fileName ?? 'unnamed'}] (download failed)`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ============================================================================
|
|
195
|
+
// Tool params
|
|
196
|
+
// ============================================================================
|
|
197
|
+
|
|
198
|
+
export interface SendTaskParams {
|
|
199
|
+
agentName: string;
|
|
200
|
+
message: string;
|
|
201
|
+
filePath?: string;
|
|
202
|
+
inputs?: Record<string, string>;
|
|
203
|
+
taskKind?: 'request' | 'pipe';
|
|
204
|
+
duration?: number;
|
|
205
|
+
timeoutMs?: number;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export interface GetTaskParams {
|
|
209
|
+
taskId: string;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export interface ListTasksParams {
|
|
213
|
+
agentName?: string;
|
|
214
|
+
state?: string;
|
|
215
|
+
limit?: number;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export interface CancelTaskParams {
|
|
219
|
+
taskId: string;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export interface ListAgentsParams {
|
|
223
|
+
tag?: string;
|
|
224
|
+
listing?: 'public' | 'private';
|
|
225
|
+
limit?: number;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export interface GetAgentCardParams {
|
|
229
|
+
agentName: string;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export interface ConnectTaskParams {
|
|
233
|
+
taskId: string;
|
|
234
|
+
timeoutMs?: number;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export interface DownloadArtifactParams {
|
|
238
|
+
taskId: string;
|
|
239
|
+
fileName: string;
|
|
240
|
+
savePath?: string;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export interface TaskIdParams {
|
|
244
|
+
taskId: string;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export interface GetAgentStatusParams {
|
|
248
|
+
agentNames: string[];
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export interface RequestTopUpParams {
|
|
252
|
+
amountUsd: number;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ============================================================================
|
|
256
|
+
// send_task
|
|
257
|
+
// ============================================================================
|
|
258
|
+
|
|
259
|
+
export async function sendTask(
|
|
260
|
+
params: SendTaskParams,
|
|
261
|
+
deps: ToolDeps,
|
|
262
|
+
): Promise<ToolResult> {
|
|
263
|
+
const baseUrl = await deps.getBaseUrl();
|
|
264
|
+
const apiKey = deps.getApiKey();
|
|
265
|
+
const entry = await deps.getAgentByName(params.agentName, { baseUrl, apiKey });
|
|
266
|
+
const billingMode: 'free' | 'paid' = entry?.billingMode ?? 'free';
|
|
267
|
+
const client = await deps.getTaskClient(billingMode);
|
|
268
|
+
|
|
269
|
+
const declaredInputs = entry?.card?.io?.inputs ?? [];
|
|
270
|
+
const textInput = declaredInputs.find((i) => isTextLikeContentType(i.contentType));
|
|
271
|
+
const fileInput = declaredInputs.find((i) => !isTextLikeContentType(i.contentType));
|
|
272
|
+
|
|
273
|
+
const requestParts: SendMessageRequestPart[] = [];
|
|
274
|
+
const textPartId = textInput?.id ?? 'text';
|
|
275
|
+
if ((textInput || declaredInputs.length === 0) && !params.inputs?.[textPartId]) {
|
|
276
|
+
requestParts.push(deps.textPart(params.message, textPartId));
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (params.filePath) {
|
|
280
|
+
const safePath = deps.validateFilePath(params.filePath);
|
|
281
|
+
const size = deps.fileSize(safePath);
|
|
282
|
+
if (size > deps.maxUploadBytes) {
|
|
283
|
+
return {
|
|
284
|
+
content: [
|
|
285
|
+
{
|
|
286
|
+
type: 'text',
|
|
287
|
+
text: `File too large (${size} bytes). Maximum upload size is ${deps.maxUploadBytes} bytes (25 MB).`,
|
|
288
|
+
},
|
|
289
|
+
],
|
|
290
|
+
isError: true,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
requestParts.push(
|
|
294
|
+
await deps.filePartFromPath(safePath, {
|
|
295
|
+
partId: fileInput?.id ?? 'file',
|
|
296
|
+
contentType: fileInput?.contentType,
|
|
297
|
+
}),
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (params.inputs) {
|
|
302
|
+
for (const [partId, value] of Object.entries(params.inputs)) {
|
|
303
|
+
requestParts.push(deps.textPart(value, partId));
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const session = await client.sendMessage({
|
|
308
|
+
agentName: params.agentName,
|
|
309
|
+
requestParts,
|
|
310
|
+
taskKind: params.taskKind,
|
|
311
|
+
duration: params.duration,
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
const timeout = params.timeoutMs ?? 60000;
|
|
315
|
+
const progressLines: string[] = [];
|
|
316
|
+
session.onProgress((event) => {
|
|
317
|
+
if (event.message) progressLines.push(`[progress] ${event.message}`);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
try {
|
|
321
|
+
const terminal = await session.waitForTerminal(timeout);
|
|
322
|
+
const output = [`Task ${session.taskId} ${terminal.state}`, ...progressLines];
|
|
323
|
+
await appendArtifacts(session, session.listArtifacts(), output);
|
|
324
|
+
if (terminal.state === 'failed') {
|
|
325
|
+
output.push(`Error: ${terminal.error ?? terminal.reason ?? 'unknown'}`);
|
|
326
|
+
}
|
|
327
|
+
session.close();
|
|
328
|
+
return { content: [{ type: 'text', text: output.join('\n') }] };
|
|
329
|
+
} catch (err) {
|
|
330
|
+
session.close();
|
|
331
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
332
|
+
return {
|
|
333
|
+
content: [{ type: 'text', text: `Task ${session.taskId} error: ${msg}` }],
|
|
334
|
+
isError: true,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ============================================================================
|
|
340
|
+
// get_task
|
|
341
|
+
// ============================================================================
|
|
342
|
+
|
|
343
|
+
export async function getTask(
|
|
344
|
+
params: GetTaskParams,
|
|
345
|
+
deps: ToolDeps,
|
|
346
|
+
): Promise<ToolResult> {
|
|
347
|
+
const freeClient = await deps.getTaskClient('free');
|
|
348
|
+
const task = await freeClient.getTask(params.taskId);
|
|
349
|
+
const output = [JSON.stringify(task, null, 2)];
|
|
350
|
+
|
|
351
|
+
if (task.state && TERMINAL_STATES.has(task.state)) {
|
|
352
|
+
try {
|
|
353
|
+
const billingMode = resolveBillingMode(task);
|
|
354
|
+
const client = await deps.getTaskClient(billingMode);
|
|
355
|
+
const session = await client.connect({ taskId: params.taskId });
|
|
356
|
+
await appendArtifacts(session, session.listArtifacts(), output);
|
|
357
|
+
session.close();
|
|
358
|
+
} catch {
|
|
359
|
+
// connect failed — return task info without artifacts
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return { content: [{ type: 'text', text: output.join('\n') }] };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// ============================================================================
|
|
367
|
+
// list_tasks
|
|
368
|
+
// ============================================================================
|
|
369
|
+
|
|
370
|
+
export async function listTasks(
|
|
371
|
+
params: ListTasksParams,
|
|
372
|
+
deps: ToolDeps,
|
|
373
|
+
): Promise<ToolResult> {
|
|
374
|
+
const client = await deps.getTaskClient();
|
|
375
|
+
const result = await client.listTasks({
|
|
376
|
+
agentName: params.agentName,
|
|
377
|
+
state: params.state,
|
|
378
|
+
limit: params.limit,
|
|
379
|
+
});
|
|
380
|
+
const lines = result.tasks.map(
|
|
381
|
+
(t: TaskInfo) =>
|
|
382
|
+
`${t.taskId} | ${t.agentName ?? '?'} | ${t.state ?? '?'} | ${t.createdTime ?? ''}`,
|
|
383
|
+
);
|
|
384
|
+
const header = `Tasks (${result.totalCount ?? result.tasks.length} total):`;
|
|
385
|
+
return { content: [{ type: 'text', text: [header, ...lines].join('\n') }] };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// ============================================================================
|
|
389
|
+
// cancel_task
|
|
390
|
+
// ============================================================================
|
|
391
|
+
|
|
392
|
+
export async function cancelTask(
|
|
393
|
+
params: CancelTaskParams,
|
|
394
|
+
deps: ToolDeps,
|
|
395
|
+
): Promise<ToolResult> {
|
|
396
|
+
const client = await deps.getTaskClient();
|
|
397
|
+
await client.cancelTask(params.taskId);
|
|
398
|
+
return { content: [{ type: 'text', text: `Task ${params.taskId} cancelled.` }] };
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// ============================================================================
|
|
402
|
+
// list_agents
|
|
403
|
+
// ============================================================================
|
|
404
|
+
|
|
405
|
+
export async function listAgents(
|
|
406
|
+
params: ListAgentsParams,
|
|
407
|
+
deps: ToolDeps,
|
|
408
|
+
): Promise<ToolResult> {
|
|
409
|
+
const baseUrl = await deps.getBaseUrl();
|
|
410
|
+
const apiKey = deps.getApiKey();
|
|
411
|
+
const result = await deps.listAgents({
|
|
412
|
+
baseUrl,
|
|
413
|
+
apiKey,
|
|
414
|
+
tag: params.tag,
|
|
415
|
+
listing: params.listing,
|
|
416
|
+
limit: params.limit,
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
const lines = result.agents.map((a) => {
|
|
420
|
+
const tags = a.tags?.map((t) => t.name).join(', ') ?? '';
|
|
421
|
+
return `${a.agentName} | ${a.name ?? a.agentName} | ${a.listing ?? 'public'} | ${tags}`;
|
|
422
|
+
});
|
|
423
|
+
const header = `Agents (${result.totalCount ?? result.agents.length}):`;
|
|
424
|
+
return { content: [{ type: 'text', text: [header, ...lines].join('\n') }] };
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// ============================================================================
|
|
428
|
+
// get_agent_card
|
|
429
|
+
// ============================================================================
|
|
430
|
+
|
|
431
|
+
export async function getAgentCard(
|
|
432
|
+
params: GetAgentCardParams,
|
|
433
|
+
deps: ToolDeps,
|
|
434
|
+
): Promise<ToolResult> {
|
|
435
|
+
const baseUrl = await deps.getBaseUrl();
|
|
436
|
+
const apiKey = deps.getApiKey();
|
|
437
|
+
const entry = await deps.getAgentByName(params.agentName, { baseUrl, apiKey });
|
|
438
|
+
if (!entry) {
|
|
439
|
+
return {
|
|
440
|
+
content: [{ type: 'text', text: `Agent "${params.agentName}" not found.` }],
|
|
441
|
+
isError: true,
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
return {
|
|
445
|
+
content: [{ type: 'text', text: JSON.stringify(entry.card ?? entry, null, 2) }],
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// ============================================================================
|
|
450
|
+
// connect_task
|
|
451
|
+
// ============================================================================
|
|
452
|
+
|
|
453
|
+
export async function connectTask(
|
|
454
|
+
params: ConnectTaskParams,
|
|
455
|
+
deps: ToolDeps,
|
|
456
|
+
): Promise<ToolResult> {
|
|
457
|
+
const freeClient = await deps.getTaskClient('free');
|
|
458
|
+
const task = await freeClient.getTask(params.taskId);
|
|
459
|
+
const billingMode = resolveBillingMode(task);
|
|
460
|
+
const client = await deps.getTaskClient(billingMode);
|
|
461
|
+
const session = await client.connect({ taskId: params.taskId });
|
|
462
|
+
|
|
463
|
+
const timeout = params.timeoutMs ?? 60000;
|
|
464
|
+
const progressLines: string[] = [];
|
|
465
|
+
const streamOutputs: Array<{ label: string; chunks: string[] }> = [];
|
|
466
|
+
const streamDrains: Promise<void>[] = [];
|
|
467
|
+
|
|
468
|
+
session.onProgress((event) => {
|
|
469
|
+
if (event.message) progressLines.push(`[progress] ${event.message}`);
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
const drainStream = (streamRef: StreamRef) => {
|
|
473
|
+
const dir = streamRef.descriptor.localDirection;
|
|
474
|
+
if (dir !== 'inbound' && dir !== 'bidirectional') return;
|
|
475
|
+
const format = streamRef.descriptor.format;
|
|
476
|
+
const label = streamRef.descriptor.declaredStream ?? streamRef.descriptor.streamId;
|
|
477
|
+
const entry = { label, chunks: [] as string[] };
|
|
478
|
+
streamOutputs.push(entry);
|
|
479
|
+
try {
|
|
480
|
+
const streamClient = streamRef.open();
|
|
481
|
+
const drain = (async () => {
|
|
482
|
+
if (format === 'events') {
|
|
483
|
+
for await (const event of streamClient.events()) {
|
|
484
|
+
entry.chunks.push(JSON.stringify(event) + '\n');
|
|
485
|
+
}
|
|
486
|
+
} else {
|
|
487
|
+
const decoder = new TextDecoder();
|
|
488
|
+
for await (const chunk of streamClient.bytes()) {
|
|
489
|
+
entry.chunks.push(decoder.decode(chunk, { stream: true }));
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
})();
|
|
493
|
+
streamDrains.push(drain.catch(() => {}));
|
|
494
|
+
} catch {
|
|
495
|
+
// stream already ended or terminal
|
|
496
|
+
}
|
|
497
|
+
};
|
|
498
|
+
|
|
499
|
+
for (const ref of session.listStreams()) drainStream(ref);
|
|
500
|
+
session.onStream(drainStream);
|
|
501
|
+
|
|
502
|
+
try {
|
|
503
|
+
const terminal = await session.waitForTerminal(timeout);
|
|
504
|
+
await Promise.allSettled(streamDrains);
|
|
505
|
+
const out: string[] = [`Task ${params.taskId} ${terminal.state}`, ...progressLines];
|
|
506
|
+
|
|
507
|
+
for (const s of streamOutputs) {
|
|
508
|
+
if (s.chunks.length > 0) {
|
|
509
|
+
out.push(`[stream: ${s.label}]\n${s.chunks.join('')}`);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
await appendArtifacts(session, session.listArtifacts(), out);
|
|
513
|
+
if (terminal.state === 'failed') {
|
|
514
|
+
out.push(`Error: ${terminal.error ?? terminal.reason ?? 'unknown'}`);
|
|
515
|
+
}
|
|
516
|
+
await session.asyncClose();
|
|
517
|
+
return { content: [{ type: 'text', text: out.join('\n') }] };
|
|
518
|
+
} catch (err) {
|
|
519
|
+
await session.asyncClose();
|
|
520
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
521
|
+
const partial: string[] = [`Task ${params.taskId} timed out (${msg})`];
|
|
522
|
+
for (const s of streamOutputs) {
|
|
523
|
+
if (s.chunks.length > 0) {
|
|
524
|
+
partial.push(`[stream: ${s.label}]\n${s.chunks.join('')}`);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
partial.push(...progressLines);
|
|
528
|
+
return { content: [{ type: 'text', text: partial.join('\n') }], isError: true };
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// ============================================================================
|
|
533
|
+
// download_artifact
|
|
534
|
+
// ============================================================================
|
|
535
|
+
|
|
536
|
+
function bytesToBase64(bytes: Uint8Array): string {
|
|
537
|
+
let binary = '';
|
|
538
|
+
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
|
|
539
|
+
return Buffer.from(binary, 'binary').toString('base64');
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
export async function downloadArtifact(
|
|
543
|
+
params: DownloadArtifactParams,
|
|
544
|
+
deps: ToolDeps,
|
|
545
|
+
): Promise<ToolResult> {
|
|
546
|
+
const freeClient = await deps.getTaskClient('free');
|
|
547
|
+
const task = await freeClient.getTask(params.taskId);
|
|
548
|
+
const billingMode = resolveBillingMode(task);
|
|
549
|
+
const client = await deps.getTaskClient(billingMode);
|
|
550
|
+
|
|
551
|
+
let session: TaskSessionLike;
|
|
552
|
+
try {
|
|
553
|
+
session = await client.connect({ taskId: params.taskId });
|
|
554
|
+
} catch (err) {
|
|
555
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
556
|
+
return {
|
|
557
|
+
content: [{ type: 'text', text: `Failed to connect to task ${params.taskId}: ${msg}` }],
|
|
558
|
+
isError: true,
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
try {
|
|
563
|
+
const refs = session.listArtifacts();
|
|
564
|
+
const ref = refs.find((r) => r.fileName === params.fileName);
|
|
565
|
+
if (!ref) {
|
|
566
|
+
const available = refs.map((r) => r.fileName ?? 'unnamed').join(', ') || '(none)';
|
|
567
|
+
return {
|
|
568
|
+
content: [
|
|
569
|
+
{
|
|
570
|
+
type: 'text',
|
|
571
|
+
text: `Artifact "${params.fileName}" not found on task ${params.taskId}. Available: ${available}`,
|
|
572
|
+
},
|
|
573
|
+
],
|
|
574
|
+
isError: true,
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const downloaded = await session.downloadArtifact(ref);
|
|
579
|
+
|
|
580
|
+
if (params.savePath) {
|
|
581
|
+
const safePath = deps.resolveSavePath(params.savePath);
|
|
582
|
+
deps.writeFile(safePath, downloaded.data);
|
|
583
|
+
return {
|
|
584
|
+
content: [
|
|
585
|
+
{
|
|
586
|
+
type: 'text',
|
|
587
|
+
text: `Saved ${downloaded.data.length} bytes to ${safePath} (${downloaded.mimeType})`,
|
|
588
|
+
},
|
|
589
|
+
],
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
if (isTextMimeType(downloaded.mimeType)) {
|
|
594
|
+
const text = new TextDecoder().decode(downloaded.data);
|
|
595
|
+
return {
|
|
596
|
+
content: [
|
|
597
|
+
{
|
|
598
|
+
type: 'text',
|
|
599
|
+
text: `[artifact: ${downloaded.fileName ?? params.fileName}] (${downloaded.mimeType}, ${downloaded.data.length} bytes)\n${text}`,
|
|
600
|
+
},
|
|
601
|
+
],
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
return {
|
|
606
|
+
content: [
|
|
607
|
+
{
|
|
608
|
+
type: 'text',
|
|
609
|
+
text: `[artifact: ${downloaded.fileName ?? params.fileName}] (${downloaded.mimeType}, ${downloaded.data.length} bytes, base64)\n${bytesToBase64(downloaded.data)}`,
|
|
610
|
+
},
|
|
611
|
+
],
|
|
612
|
+
};
|
|
613
|
+
} finally {
|
|
614
|
+
session.close();
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// ============================================================================
|
|
619
|
+
// pause_task / resume_task / retry_task
|
|
620
|
+
// ============================================================================
|
|
621
|
+
|
|
622
|
+
export async function pauseTask(
|
|
623
|
+
params: TaskIdParams,
|
|
624
|
+
deps: ToolDeps,
|
|
625
|
+
): Promise<ToolResult> {
|
|
626
|
+
const client = await deps.getTaskClient();
|
|
627
|
+
await client.pauseTask(params.taskId);
|
|
628
|
+
return { content: [{ type: 'text', text: `Task ${params.taskId} paused.` }] };
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
export async function resumeTask(
|
|
632
|
+
params: TaskIdParams,
|
|
633
|
+
deps: ToolDeps,
|
|
634
|
+
): Promise<ToolResult> {
|
|
635
|
+
const client = await deps.getTaskClient();
|
|
636
|
+
await client.resumeTask(params.taskId);
|
|
637
|
+
return { content: [{ type: 'text', text: `Task ${params.taskId} resumed.` }] };
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
export async function retryTask(
|
|
641
|
+
params: TaskIdParams,
|
|
642
|
+
deps: ToolDeps,
|
|
643
|
+
): Promise<ToolResult> {
|
|
644
|
+
const client = await deps.getTaskClient();
|
|
645
|
+
await client.retryTask(params.taskId);
|
|
646
|
+
return { content: [{ type: 'text', text: `Task ${params.taskId} retry requested.` }] };
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// ============================================================================
|
|
650
|
+
// get_agent_status
|
|
651
|
+
// ============================================================================
|
|
652
|
+
|
|
653
|
+
export async function getAgentStatus(
|
|
654
|
+
params: GetAgentStatusParams,
|
|
655
|
+
deps: ToolDeps,
|
|
656
|
+
): Promise<ToolResult> {
|
|
657
|
+
const baseUrl = await deps.getBaseUrl();
|
|
658
|
+
const apiKey = deps.getApiKey();
|
|
659
|
+
const result = await deps.fetchAgentStatus({
|
|
660
|
+
baseUrl,
|
|
661
|
+
apiKey,
|
|
662
|
+
agentNames: params.agentNames,
|
|
663
|
+
});
|
|
664
|
+
return {
|
|
665
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// ============================================================================
|
|
670
|
+
// check_balance / request_topup
|
|
671
|
+
// ============================================================================
|
|
672
|
+
|
|
673
|
+
function requireBillingContext(deps: ToolDeps): { apiKey: string; orgId: string } | ToolResult {
|
|
674
|
+
const apiKey = deps.getApiKey();
|
|
675
|
+
if (!apiKey) {
|
|
676
|
+
return {
|
|
677
|
+
content: [{ type: 'text', text: 'BLOCKS_API_KEY is required for billing operations.' }],
|
|
678
|
+
isError: true,
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
const orgId = deps.getOrgId();
|
|
682
|
+
if (!orgId) {
|
|
683
|
+
return {
|
|
684
|
+
content: [
|
|
685
|
+
{
|
|
686
|
+
type: 'text',
|
|
687
|
+
text: 'BLOCKS_ORG_ID is required for billing operations. Set it from your dashboard URL or `blocks whoami --json`.',
|
|
688
|
+
},
|
|
689
|
+
],
|
|
690
|
+
isError: true,
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
return { apiKey, orgId };
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
export async function checkBalance(
|
|
697
|
+
_params: Record<string, never>,
|
|
698
|
+
deps: ToolDeps,
|
|
699
|
+
): Promise<ToolResult> {
|
|
700
|
+
const ctx = requireBillingContext(deps);
|
|
701
|
+
if ('content' in ctx) return ctx;
|
|
702
|
+
const baseUrl = await deps.getBaseUrl();
|
|
703
|
+
const balance = await deps.getConsumerBalance({
|
|
704
|
+
baseUrl,
|
|
705
|
+
apiKey: ctx.apiKey,
|
|
706
|
+
orgId: ctx.orgId,
|
|
707
|
+
});
|
|
708
|
+
return {
|
|
709
|
+
content: [{ type: 'text', text: JSON.stringify(balance, null, 2) }],
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
export async function requestTopup(
|
|
714
|
+
params: RequestTopUpParams,
|
|
715
|
+
deps: ToolDeps,
|
|
716
|
+
): Promise<ToolResult> {
|
|
717
|
+
const ctx = requireBillingContext(deps);
|
|
718
|
+
if ('content' in ctx) return ctx;
|
|
719
|
+
const baseUrl = await deps.getBaseUrl();
|
|
720
|
+
const session = await deps.createConsumerTopUp({
|
|
721
|
+
baseUrl,
|
|
722
|
+
apiKey: ctx.apiKey,
|
|
723
|
+
orgId: ctx.orgId,
|
|
724
|
+
amountUsd: params.amountUsd,
|
|
725
|
+
});
|
|
726
|
+
const url = session.checkoutUrl;
|
|
727
|
+
const body = url
|
|
728
|
+
? `Open this URL in a browser to complete the $${params.amountUsd.toFixed(2)} top-up:\n${url}`
|
|
729
|
+
: JSON.stringify(session, null, 2);
|
|
730
|
+
return { content: [{ type: 'text', text: body }] };
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// ============================================================================
|
|
734
|
+
// Default file helpers (re-exported so index.ts can build a default ToolDeps)
|
|
735
|
+
// ============================================================================
|
|
736
|
+
|
|
737
|
+
export function defaultFileSize(path: string): number {
|
|
738
|
+
return statSync(path).size;
|
|
739
|
+
}
|