@blocks-network/mcp-server 0.1.62 → 0.1.65
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/LICENSE +26 -0
- package/README.md +33 -20
- 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 +77 -8
- package/dist/protocol-headers.d.ts +11 -0
- package/dist/protocol-headers.js +11 -0
- package/dist/registry-list.d.ts +24 -4
- package/dist/registry-list.js +50 -4
- package/dist/tools.d.ts +71 -3
- package/dist/tools.js +245 -6
- package/package.json +1 -1
- package/src/agent-status.ts +89 -0
- package/src/billing.ts +122 -0
- package/src/index.ts +136 -7
- package/src/protocol-headers.ts +12 -0
- package/src/registry-list.ts +68 -5
- package/src/tools.ts +368 -9
- package/tests/billing.test.ts +203 -0
- package/tests/download-artifact.test.ts +142 -0
- package/tests/get-agent-status.test.ts +108 -0
- package/tests/helpers.ts +75 -13
- package/tests/list-agents.test.ts +107 -10
- package/tests/registry-list.test.ts +128 -4
- package/tests/search-agent.test.ts +119 -0
- package/tests/task-lifecycle.test.ts +64 -0
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 } 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';
|
|
@@ -14,15 +14,25 @@ import {
|
|
|
14
14
|
BLOCKS_MAX_UPLOAD_BYTES,
|
|
15
15
|
} from '@blocks-network/sdk';
|
|
16
16
|
|
|
17
|
-
import {
|
|
17
|
+
import { listAllAgentsAuthenticated } from './registry-list.js';
|
|
18
|
+
import { fetchAgentStatus, MAX_AGENT_NAMES } from './agent-status.js';
|
|
19
|
+
import { getConsumerBalance, createConsumerTopUp } from './billing.js';
|
|
18
20
|
import {
|
|
19
21
|
sendTask,
|
|
20
22
|
getTask,
|
|
21
23
|
listTasks,
|
|
22
24
|
cancelTask,
|
|
25
|
+
pauseTask,
|
|
26
|
+
resumeTask,
|
|
27
|
+
retryTask,
|
|
23
28
|
listAgents,
|
|
29
|
+
searchAgents,
|
|
24
30
|
getAgentCard,
|
|
31
|
+
getAgentStatus,
|
|
25
32
|
connectTask,
|
|
33
|
+
downloadArtifact,
|
|
34
|
+
checkBalance,
|
|
35
|
+
requestTopup,
|
|
26
36
|
defaultFileSize,
|
|
27
37
|
type ToolDeps,
|
|
28
38
|
type TaskClientLike,
|
|
@@ -51,6 +61,21 @@ function validateFilePath(filePath: string): string {
|
|
|
51
61
|
return real;
|
|
52
62
|
}
|
|
53
63
|
|
|
64
|
+
function resolveSavePath(filePath: string): string {
|
|
65
|
+
const root = realpathSync(resolve(ALLOWED_ROOT));
|
|
66
|
+
const resolved = resolve(root, filePath);
|
|
67
|
+
const rel = relative(root, resolved);
|
|
68
|
+
if (rel.startsWith('..') || resolve(root, rel) !== resolved) {
|
|
69
|
+
throw new Error(`Save path must be within ${ALLOWED_ROOT}`);
|
|
70
|
+
}
|
|
71
|
+
mkdirSync(dirname(resolved), { recursive: true });
|
|
72
|
+
return resolved;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function writeFile(filePath: string, data: Uint8Array): void {
|
|
76
|
+
writeFileSync(filePath, data);
|
|
77
|
+
}
|
|
78
|
+
|
|
54
79
|
const taskClients = new Map<string, TaskClient>();
|
|
55
80
|
let resolvedBaseUrl: string | undefined;
|
|
56
81
|
|
|
@@ -78,11 +103,17 @@ async function getTaskClient(billingMode: 'free' | 'paid' = 'free'): Promise<Tas
|
|
|
78
103
|
const deps: ToolDeps = {
|
|
79
104
|
getBaseUrl,
|
|
80
105
|
getApiKey: () => process.env.BLOCKS_API_KEY,
|
|
106
|
+
getOrgId: () => process.env.BLOCKS_ORG_ID,
|
|
81
107
|
getTaskClient,
|
|
82
108
|
getAgentByName: (agentName, options) =>
|
|
83
109
|
getAgent(agentName, options) as Promise<AgentEntryLike | null>,
|
|
84
|
-
listAgents:
|
|
110
|
+
listAgents: listAllAgentsAuthenticated,
|
|
111
|
+
fetchAgentStatus,
|
|
112
|
+
getConsumerBalance,
|
|
113
|
+
createConsumerTopUp,
|
|
85
114
|
validateFilePath,
|
|
115
|
+
resolveSavePath,
|
|
116
|
+
writeFile,
|
|
86
117
|
fileSize: defaultFileSize,
|
|
87
118
|
maxUploadBytes: BLOCKS_MAX_UPLOAD_BYTES,
|
|
88
119
|
filePartFromPath,
|
|
@@ -140,20 +171,70 @@ server.tool(
|
|
|
140
171
|
(params) => cancelTask(params, deps),
|
|
141
172
|
);
|
|
142
173
|
|
|
174
|
+
server.tool(
|
|
175
|
+
'pause_task',
|
|
176
|
+
'Pause a running pipe task. Resume later with resume_task.',
|
|
177
|
+
{ taskId: z.string().describe('The task ID to pause') },
|
|
178
|
+
(params) => pauseTask(params, deps),
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
server.tool(
|
|
182
|
+
'resume_task',
|
|
183
|
+
'Resume a paused pipe task',
|
|
184
|
+
{ taskId: z.string().describe('The task ID to resume') },
|
|
185
|
+
(params) => resumeTask(params, deps),
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
server.tool(
|
|
189
|
+
'retry_task',
|
|
190
|
+
'Retry a failed task',
|
|
191
|
+
{ taskId: z.string().describe('The task ID to retry') },
|
|
192
|
+
(params) => retryTask(params, deps),
|
|
193
|
+
);
|
|
194
|
+
|
|
143
195
|
server.tool(
|
|
144
196
|
'list_agents',
|
|
145
|
-
'List available agents in the Blocks Network registry. Use listing="private" with an API key to discover your private 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.',
|
|
146
198
|
{
|
|
147
|
-
|
|
199
|
+
tag: z.string().optional().describe('Filter by tag slug'),
|
|
148
200
|
listing: z
|
|
149
201
|
.enum(['public', 'private'])
|
|
150
202
|
.optional()
|
|
151
203
|
.describe('Filter by listing visibility (default: public)'),
|
|
152
|
-
limit: z
|
|
204
|
+
limit: z
|
|
205
|
+
.number()
|
|
206
|
+
.optional()
|
|
207
|
+
.describe('Max agents to fetch across all pages; omit to fetch all'),
|
|
208
|
+
includeOffline: z
|
|
209
|
+
.boolean()
|
|
210
|
+
.optional()
|
|
211
|
+
.describe('Include agents with no online instances (default: false)'),
|
|
153
212
|
},
|
|
154
213
|
(params) => listAgents(params, deps),
|
|
155
214
|
);
|
|
156
215
|
|
|
216
|
+
server.tool(
|
|
217
|
+
'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.',
|
|
219
|
+
{
|
|
220
|
+
query: z.string().describe('Free-text search query'),
|
|
221
|
+
tag: z.string().optional().describe('Additionally filter by tag slug'),
|
|
222
|
+
listing: z
|
|
223
|
+
.enum(['public', 'private'])
|
|
224
|
+
.optional()
|
|
225
|
+
.describe('Filter by listing visibility (default: public)'),
|
|
226
|
+
limit: z
|
|
227
|
+
.number()
|
|
228
|
+
.optional()
|
|
229
|
+
.describe('Max matching agents to fetch across all pages; omit to fetch all'),
|
|
230
|
+
includeOffline: z
|
|
231
|
+
.boolean()
|
|
232
|
+
.optional()
|
|
233
|
+
.describe('Include agents with no online instances (default: false)'),
|
|
234
|
+
},
|
|
235
|
+
(params) => searchAgents(params, deps),
|
|
236
|
+
);
|
|
237
|
+
|
|
157
238
|
server.tool(
|
|
158
239
|
'get_agent_card',
|
|
159
240
|
'Get the full agent card for a specific agent',
|
|
@@ -161,6 +242,40 @@ server.tool(
|
|
|
161
242
|
(params) => getAgentCard(params, deps),
|
|
162
243
|
);
|
|
163
244
|
|
|
245
|
+
server.tool(
|
|
246
|
+
'get_agent_status',
|
|
247
|
+
'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.)',
|
|
248
|
+
{
|
|
249
|
+
agentNames: z
|
|
250
|
+
.array(z.string())
|
|
251
|
+
.min(1)
|
|
252
|
+
.max(MAX_AGENT_NAMES)
|
|
253
|
+
.describe(`Agent names to check (1-${MAX_AGENT_NAMES})`),
|
|
254
|
+
},
|
|
255
|
+
(params) => getAgentStatus(params, deps),
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
server.tool(
|
|
259
|
+
'check_balance',
|
|
260
|
+
'Get the consumer billing balance for the configured org (BLOCKS_ORG_ID): ledger balance, active reservations, and available balance.',
|
|
261
|
+
{},
|
|
262
|
+
(_params) => checkBalance({}, deps),
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
server.tool(
|
|
266
|
+
'request_topup',
|
|
267
|
+
'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`).',
|
|
268
|
+
{
|
|
269
|
+
amountUsd: z
|
|
270
|
+
.number()
|
|
271
|
+
.positive()
|
|
272
|
+
.describe(
|
|
273
|
+
'Amount to add in USD (e.g. 25 or 19.99). Whole-cent precision. Must be at least $5 (platform minimum).',
|
|
274
|
+
),
|
|
275
|
+
},
|
|
276
|
+
(params) => requestTopup(params, deps),
|
|
277
|
+
);
|
|
278
|
+
|
|
164
279
|
server.tool(
|
|
165
280
|
'connect_task',
|
|
166
281
|
'Connect to an existing task, stream events and data until completion',
|
|
@@ -171,6 +286,20 @@ server.tool(
|
|
|
171
286
|
(params) => connectTask(params, deps),
|
|
172
287
|
);
|
|
173
288
|
|
|
289
|
+
server.tool(
|
|
290
|
+
'download_artifact',
|
|
291
|
+
'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).',
|
|
292
|
+
{
|
|
293
|
+
taskId: z.string().describe('The task ID that produced the artifact'),
|
|
294
|
+
fileName: z.string().describe('Artifact file name as listed by get_task'),
|
|
295
|
+
savePath: z
|
|
296
|
+
.string()
|
|
297
|
+
.optional()
|
|
298
|
+
.describe('Optional path (relative to BLOCKS_MCP_FILE_ROOT) to write the artifact to'),
|
|
299
|
+
},
|
|
300
|
+
(params) => downloadArtifact(params, deps),
|
|
301
|
+
);
|
|
302
|
+
|
|
174
303
|
function shutdown() {
|
|
175
304
|
for (const client of taskClients.values()) {
|
|
176
305
|
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';
|
package/src/registry-list.ts
CHANGED
|
@@ -6,8 +6,10 @@
|
|
|
6
6
|
* optional Bearer API key for private/owned listings.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
import {
|
|
10
|
+
PROTOCOL_VERSION_HEADER,
|
|
11
|
+
CURRENT_PROTOCOL_VERSION,
|
|
12
|
+
} from './protocol-headers.js';
|
|
11
13
|
|
|
12
14
|
export interface AgentListEntry {
|
|
13
15
|
agentName: string;
|
|
@@ -15,33 +17,50 @@ export interface AgentListEntry {
|
|
|
15
17
|
description?: string;
|
|
16
18
|
listing?: string;
|
|
17
19
|
billingMode?: string;
|
|
18
|
-
|
|
20
|
+
tags?: Array<{ id: string; name: string }>;
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
export interface ListAgentsOptions {
|
|
22
24
|
baseUrl: string;
|
|
23
25
|
apiKey?: string;
|
|
24
|
-
|
|
26
|
+
/** Free-text search query (`q`); matches agent name, description, tags, etc. */
|
|
27
|
+
q?: string;
|
|
28
|
+
tag?: string;
|
|
25
29
|
listing?: 'public' | 'private';
|
|
30
|
+
/** Page size for a single request. The backend caps this at 100. */
|
|
26
31
|
limit?: number;
|
|
32
|
+
/** Opaque pagination cursor returned as `next` by a previous request. */
|
|
33
|
+
cursor?: string;
|
|
27
34
|
fetchImpl?: typeof fetch;
|
|
28
35
|
}
|
|
29
36
|
|
|
30
37
|
export interface ListAgentsResult {
|
|
31
38
|
agents: AgentListEntry[];
|
|
32
39
|
totalCount?: number;
|
|
40
|
+
/** Opaque cursor for the next page, or null/absent when there are no more. */
|
|
41
|
+
next?: string | null;
|
|
42
|
+
/** Total agents with at least one online instance (ignores cursor). */
|
|
43
|
+
totalOnlineCount?: number;
|
|
33
44
|
}
|
|
34
45
|
|
|
46
|
+
/** Backend maximum page size for GET /api/v1/registry/agents. */
|
|
47
|
+
export const REGISTRY_PAGE_SIZE = 100;
|
|
48
|
+
|
|
49
|
+
/** Safety backstop so a misbehaving (never-null) cursor can't loop forever. */
|
|
50
|
+
const MAX_PAGES = 1000;
|
|
51
|
+
|
|
35
52
|
export async function listAgentsAuthenticated(
|
|
36
53
|
opts: ListAgentsOptions,
|
|
37
54
|
): Promise<ListAgentsResult> {
|
|
38
55
|
const params = new URLSearchParams({ include: 'full' });
|
|
39
|
-
if (opts.
|
|
56
|
+
if (opts.q) params.set('q', opts.q);
|
|
57
|
+
if (opts.tag) params.set('tag', opts.tag);
|
|
40
58
|
if (opts.listing) {
|
|
41
59
|
params.set('listing', opts.listing);
|
|
42
60
|
if (opts.listing === 'private') params.set('scope', 'owned');
|
|
43
61
|
}
|
|
44
62
|
if (opts.limit) params.set('limit', String(opts.limit));
|
|
63
|
+
if (opts.cursor) params.set('cursor', opts.cursor);
|
|
45
64
|
|
|
46
65
|
const url = `${opts.baseUrl.replace(/\/+$/, '')}/api/v1/registry/agents?${params}`;
|
|
47
66
|
const headers: Record<string, string> = {
|
|
@@ -56,3 +75,47 @@ export async function listAgentsAuthenticated(
|
|
|
56
75
|
}
|
|
57
76
|
return (await response.json()) as ListAgentsResult;
|
|
58
77
|
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Fetch every agent by following the `next` cursor until it's null/absent.
|
|
81
|
+
*
|
|
82
|
+
* Uses the backend's maximum page size (100) to minimise round-trips. The
|
|
83
|
+
* optional `maxAgents` cap bounds the total number of agents collected and
|
|
84
|
+
* shrinks the final page request so we never over-fetch. The latest
|
|
85
|
+
* `totalCount`/`totalOnlineCount` seen are carried forward to the result.
|
|
86
|
+
*/
|
|
87
|
+
export async function listAllAgentsAuthenticated(
|
|
88
|
+
opts: ListAgentsOptions & { maxAgents?: number },
|
|
89
|
+
): Promise<ListAgentsResult> {
|
|
90
|
+
const agents: AgentListEntry[] = [];
|
|
91
|
+
let cursor = opts.cursor;
|
|
92
|
+
let totalCount: number | undefined;
|
|
93
|
+
let totalOnlineCount: number | undefined;
|
|
94
|
+
const cap = opts.maxAgents;
|
|
95
|
+
|
|
96
|
+
for (let page = 0; page < MAX_PAGES; page++) {
|
|
97
|
+
const remaining = cap !== undefined ? cap - agents.length : undefined;
|
|
98
|
+
if (remaining !== undefined && remaining <= 0) break;
|
|
99
|
+
const pageSize =
|
|
100
|
+
remaining !== undefined
|
|
101
|
+
? Math.min(REGISTRY_PAGE_SIZE, remaining)
|
|
102
|
+
: REGISTRY_PAGE_SIZE;
|
|
103
|
+
|
|
104
|
+
const result = await listAgentsAuthenticated({
|
|
105
|
+
...opts,
|
|
106
|
+
limit: pageSize,
|
|
107
|
+
cursor,
|
|
108
|
+
});
|
|
109
|
+
agents.push(...result.agents);
|
|
110
|
+
if (result.totalCount !== undefined) totalCount = result.totalCount;
|
|
111
|
+
if (result.totalOnlineCount !== undefined) {
|
|
112
|
+
totalOnlineCount = result.totalOnlineCount;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
cursor = result.next ?? undefined;
|
|
116
|
+
if (!cursor) break;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const trimmed = cap !== undefined ? agents.slice(0, cap) : agents;
|
|
120
|
+
return { agents: trimmed, totalCount, totalOnlineCount, next: null };
|
|
121
|
+
}
|