@blocks-network/mcp-server 0.1.1
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 +120 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +390 -0
- package/package.json +30 -0
- package/src/index.ts +479 -0
- package/tsconfig.json +15 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
Blocks Network Software Development Kit License Agreement
|
|
2
|
+
Copyright © 2026 Blocks Network Inc. All rights reserved.
|
|
3
|
+
|
|
4
|
+
Subject to the terms and conditions of the license, you are hereby granted
|
|
5
|
+
a non-exclusive, worldwide, royalty-free license to (a) copy and modify
|
|
6
|
+
the software in source code or binary form for use with the software services
|
|
7
|
+
and interfaces provided by Blocks Network, and (b) redistribute unmodified copies
|
|
8
|
+
of the software to third parties. The software may not be incorporated in
|
|
9
|
+
or used to provide any product or service competitive with the products
|
|
10
|
+
and services of Blocks Network.
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this license shall be included
|
|
13
|
+
in or with all copies or substantial portions of the software.
|
|
14
|
+
|
|
15
|
+
This license does not grant you permission to use the trade names, trademarks,
|
|
16
|
+
service marks, or product names of Blocks Network, except as required for reasonable
|
|
17
|
+
and customary use in describing the origin of the software and reproducing
|
|
18
|
+
the content of this license.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF
|
|
21
|
+
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
22
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
|
|
23
|
+
EVENT SHALL BLOCKS NETWORK OR THE AUTHORS OR COPYRIGHT HOLDERS OF THE SOFTWARE BE
|
|
24
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
|
|
25
|
+
CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
|
26
|
+
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# Blocks Network MCP Server
|
|
2
|
+
|
|
3
|
+
MCP (Model Context Protocol) server that exposes Blocks Network consumer operations as tools for AI assistants.
|
|
4
|
+
|
|
5
|
+
## Setup
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
cd mcp
|
|
9
|
+
npm install
|
|
10
|
+
npm run build
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Environment Variables
|
|
14
|
+
|
|
15
|
+
| Variable | Required | Description |
|
|
16
|
+
|----------|----------|-------------|
|
|
17
|
+
| `BLOCKS_API_KEY` | Yes | Your Blocks Network API key |
|
|
18
|
+
| `BLOCKS_MCP_FILE_ROOT` | No | Allowed root directory for file uploads (default: cwd) |
|
|
19
|
+
|
|
20
|
+
All other configuration (keys, endpoints) is resolved automatically from CDM.
|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
|
|
24
|
+
### Claude Code (CLI)
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
claude mcp add blocks-network -- node /path/to/blocks-sdk/mcp/dist/index.js
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Or add to your `.claude/settings.json`:
|
|
31
|
+
|
|
32
|
+
```json
|
|
33
|
+
{
|
|
34
|
+
"mcpServers": {
|
|
35
|
+
"blocks-network": {
|
|
36
|
+
"command": "node",
|
|
37
|
+
"args": ["/path/to/blocks-sdk/mcp/dist/index.js"],
|
|
38
|
+
"env": {
|
|
39
|
+
"BLOCKS_API_KEY": "your-api-key"
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Claude Desktop
|
|
47
|
+
|
|
48
|
+
Add to your `claude_desktop_config.json`:
|
|
49
|
+
|
|
50
|
+
```json
|
|
51
|
+
{
|
|
52
|
+
"mcpServers": {
|
|
53
|
+
"blocks-network": {
|
|
54
|
+
"command": "node",
|
|
55
|
+
"args": ["/path/to/blocks-sdk/mcp/dist/index.js"],
|
|
56
|
+
"env": {
|
|
57
|
+
"BLOCKS_API_KEY": "your-api-key"
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### OpenAI Codex CLI
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
codex --mcp-config mcp.json
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Create an `mcp.json` file:
|
|
71
|
+
|
|
72
|
+
```json
|
|
73
|
+
{
|
|
74
|
+
"mcpServers": {
|
|
75
|
+
"blocks-network": {
|
|
76
|
+
"command": "node",
|
|
77
|
+
"args": ["/path/to/blocks-sdk/mcp/dist/index.js"],
|
|
78
|
+
"env": {
|
|
79
|
+
"BLOCKS_API_KEY": "your-api-key"
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Gemini CLI
|
|
87
|
+
|
|
88
|
+
Add to your `~/.gemini/settings.json`:
|
|
89
|
+
|
|
90
|
+
```json
|
|
91
|
+
{
|
|
92
|
+
"mcpServers": {
|
|
93
|
+
"blocks-network": {
|
|
94
|
+
"command": "node",
|
|
95
|
+
"args": ["/path/to/blocks-sdk/mcp/dist/index.js"],
|
|
96
|
+
"env": {
|
|
97
|
+
"BLOCKS_API_KEY": "your-api-key"
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Available Tools
|
|
105
|
+
|
|
106
|
+
| Tool | Description |
|
|
107
|
+
|------|-------------|
|
|
108
|
+
| `send_task` | Send a task to an agent and wait for the result |
|
|
109
|
+
| `get_task` | Get the current status of a task |
|
|
110
|
+
| `list_tasks` | List tasks, optionally filtered by agent or state |
|
|
111
|
+
| `cancel_task` | Cancel a running task |
|
|
112
|
+
| `list_agents` | List available agents in the registry |
|
|
113
|
+
| `get_agent_card` | Get the full agent card for a specific agent |
|
|
114
|
+
| `connect_task` | Connect to an existing task and stream events |
|
|
115
|
+
|
|
116
|
+
## Development
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
npm run dev
|
|
120
|
+
```
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { resolve, relative } from 'node:path';
|
|
3
|
+
import { realpathSync, statSync } from 'node:fs';
|
|
4
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
5
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
import { TaskClient, textPart, filePartFromPath, fetchCdmConfig, getAgent, BLOCKS_MAX_UPLOAD_BYTES, } from '@blocks-network/sdk';
|
|
8
|
+
const ALLOWED_ROOT = process.env.BLOCKS_MCP_FILE_ROOT ?? process.cwd();
|
|
9
|
+
if (!process.env.BLOCKS_API_KEY) {
|
|
10
|
+
console.error('Warning: BLOCKS_API_KEY is not set. Private agents and paid tasks will not work.');
|
|
11
|
+
}
|
|
12
|
+
function validateFilePath(filePath) {
|
|
13
|
+
const resolved = resolve(filePath);
|
|
14
|
+
let real;
|
|
15
|
+
try {
|
|
16
|
+
real = realpathSync(resolved);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
throw new Error(`File not found: ${filePath}`);
|
|
20
|
+
}
|
|
21
|
+
const root = realpathSync(resolve(ALLOWED_ROOT));
|
|
22
|
+
const rel = relative(root, real);
|
|
23
|
+
if (rel.startsWith('..') || resolve(root, rel) !== real) {
|
|
24
|
+
throw new Error(`File path must be within ${ALLOWED_ROOT}`);
|
|
25
|
+
}
|
|
26
|
+
return real;
|
|
27
|
+
}
|
|
28
|
+
function resolveBillingMode(task) {
|
|
29
|
+
const mode = task.billingMode;
|
|
30
|
+
if (mode === 'paid')
|
|
31
|
+
return 'paid';
|
|
32
|
+
return 'free';
|
|
33
|
+
}
|
|
34
|
+
const taskClients = new Map();
|
|
35
|
+
let resolvedBaseUrl;
|
|
36
|
+
async function getBaseUrl() {
|
|
37
|
+
if (resolvedBaseUrl)
|
|
38
|
+
return resolvedBaseUrl;
|
|
39
|
+
if (process.env.BLOCKS_BACKEND_URL) {
|
|
40
|
+
resolvedBaseUrl = process.env.BLOCKS_BACKEND_URL;
|
|
41
|
+
return resolvedBaseUrl;
|
|
42
|
+
}
|
|
43
|
+
const cdm = await fetchCdmConfig(process.env.BLOCKS_CDM_URL);
|
|
44
|
+
resolvedBaseUrl = cdm.api.baseUrl;
|
|
45
|
+
return resolvedBaseUrl;
|
|
46
|
+
}
|
|
47
|
+
async function listAgentsAuthenticated(opts) {
|
|
48
|
+
const params = new URLSearchParams({ include: 'full' });
|
|
49
|
+
if (opts.skill)
|
|
50
|
+
params.set('skill', opts.skill);
|
|
51
|
+
if (opts.listing) {
|
|
52
|
+
params.set('listing', opts.listing);
|
|
53
|
+
if (opts.listing === 'private')
|
|
54
|
+
params.set('scope', 'owned');
|
|
55
|
+
}
|
|
56
|
+
if (opts.limit)
|
|
57
|
+
params.set('limit', String(opts.limit));
|
|
58
|
+
const url = `${opts.baseUrl.replace(/\/+$/, '')}/api/v1/registry/agents?${params}`;
|
|
59
|
+
const headers = {};
|
|
60
|
+
if (opts.apiKey)
|
|
61
|
+
headers['Authorization'] = `Bearer ${opts.apiKey}`;
|
|
62
|
+
const response = await fetch(url, { headers });
|
|
63
|
+
if (!response.ok) {
|
|
64
|
+
throw new Error(`Registry list failed: HTTP ${response.status}`);
|
|
65
|
+
}
|
|
66
|
+
const data = await response.json();
|
|
67
|
+
return data;
|
|
68
|
+
}
|
|
69
|
+
async function getTaskClient(billingMode = 'free') {
|
|
70
|
+
let client = taskClients.get(billingMode);
|
|
71
|
+
if (!client) {
|
|
72
|
+
const apiKey = process.env.BLOCKS_API_KEY;
|
|
73
|
+
client = await TaskClient.create({ apiKey, billingMode });
|
|
74
|
+
taskClients.set(billingMode, client);
|
|
75
|
+
}
|
|
76
|
+
return client;
|
|
77
|
+
}
|
|
78
|
+
const server = new McpServer({
|
|
79
|
+
name: 'blocks-network',
|
|
80
|
+
version: '0.1.0',
|
|
81
|
+
});
|
|
82
|
+
// ============================================================================
|
|
83
|
+
// Tool: send_task
|
|
84
|
+
// ============================================================================
|
|
85
|
+
server.tool('send_task', 'Send a task to a Blocks Network agent and wait for the result', {
|
|
86
|
+
agentName: z.string().describe('Name of the target agent'),
|
|
87
|
+
message: z.string().describe('Text message to send to the agent'),
|
|
88
|
+
filePath: z.string().optional().describe('Optional file path to attach'),
|
|
89
|
+
inputs: z.record(z.string(), z.string()).optional().describe('Additional named inputs as {partId: value} pairs'),
|
|
90
|
+
taskKind: z.enum(['request', 'pipe']).optional().describe('Task kind (default: request)'),
|
|
91
|
+
duration: z.number().optional().describe('Duration in minutes (required for pipe tasks)'),
|
|
92
|
+
timeoutMs: z.number().optional().describe('Timeout in milliseconds to wait for result (default: 60000)'),
|
|
93
|
+
}, async ({ agentName, message, filePath, inputs, taskKind, duration, timeoutMs }) => {
|
|
94
|
+
const baseUrl = await getBaseUrl();
|
|
95
|
+
const apiKey = process.env.BLOCKS_API_KEY;
|
|
96
|
+
const entry = await getAgent(agentName, { baseUrl, apiKey });
|
|
97
|
+
const billingMode = entry?.billingMode ?? 'free';
|
|
98
|
+
const client = await getTaskClient(billingMode);
|
|
99
|
+
const declaredInputs = entry?.card?.io?.inputs ?? [];
|
|
100
|
+
const isTextLike = (ct) => ct.startsWith('text/') || ct === 'application/json' || ct.endsWith('+json');
|
|
101
|
+
const textInput = declaredInputs.find((i) => isTextLike(i.contentType));
|
|
102
|
+
const fileInput = declaredInputs.find((i) => !isTextLike(i.contentType));
|
|
103
|
+
const requestParts = [];
|
|
104
|
+
const textPartId = textInput?.id ?? 'text';
|
|
105
|
+
if ((textInput || declaredInputs.length === 0) && !inputs?.[textPartId]) {
|
|
106
|
+
requestParts.push(textPart(message, textPartId));
|
|
107
|
+
}
|
|
108
|
+
if (filePath) {
|
|
109
|
+
const safePath = validateFilePath(filePath);
|
|
110
|
+
const fileSize = statSync(safePath).size;
|
|
111
|
+
if (fileSize > BLOCKS_MAX_UPLOAD_BYTES) {
|
|
112
|
+
return {
|
|
113
|
+
content: [{ type: 'text', text: `File too large (${fileSize} bytes). Maximum upload size is ${BLOCKS_MAX_UPLOAD_BYTES} bytes (25 MB).` }],
|
|
114
|
+
isError: true,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
requestParts.push(await filePartFromPath(safePath, {
|
|
118
|
+
partId: fileInput?.id ?? 'file',
|
|
119
|
+
contentType: fileInput?.contentType,
|
|
120
|
+
}));
|
|
121
|
+
}
|
|
122
|
+
if (inputs) {
|
|
123
|
+
for (const [partId, value] of Object.entries(inputs)) {
|
|
124
|
+
requestParts.push(textPart(value, partId));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const session = await client.sendMessage({
|
|
128
|
+
agentName,
|
|
129
|
+
requestParts,
|
|
130
|
+
taskKind,
|
|
131
|
+
duration,
|
|
132
|
+
});
|
|
133
|
+
const timeout = timeoutMs ?? 60000;
|
|
134
|
+
const results = [];
|
|
135
|
+
session.onProgress((event) => {
|
|
136
|
+
if (event.message) {
|
|
137
|
+
results.push(`[progress] ${event.message}`);
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
try {
|
|
141
|
+
const terminal = await session.waitForTerminal(timeout);
|
|
142
|
+
const allArtifacts = session.listArtifacts();
|
|
143
|
+
const output = [`Task ${session.taskId} ${terminal.state}`, ...results];
|
|
144
|
+
for (const ref of allArtifacts) {
|
|
145
|
+
try {
|
|
146
|
+
const downloaded = await session.downloadArtifact(ref);
|
|
147
|
+
const isText = downloaded.mimeType.startsWith('text/') ||
|
|
148
|
+
downloaded.mimeType === 'application/json';
|
|
149
|
+
if (isText) {
|
|
150
|
+
const text = new TextDecoder().decode(downloaded.data);
|
|
151
|
+
output.push(`[artifact: ${downloaded.fileName ?? 'unnamed'}]\n${text}`);
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
output.push(`[artifact: ${downloaded.fileName ?? 'unnamed'}] (${downloaded.mimeType}, ${downloaded.data.length} bytes)`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
output.push(`[artifact: ${ref.fileName ?? 'unnamed'}] (download failed)`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (terminal.state === 'failed') {
|
|
162
|
+
output.push(`Error: ${terminal.error ?? terminal.reason ?? 'unknown'}`);
|
|
163
|
+
}
|
|
164
|
+
session.close();
|
|
165
|
+
return { content: [{ type: 'text', text: output.join('\n') }] };
|
|
166
|
+
}
|
|
167
|
+
catch (err) {
|
|
168
|
+
session.close();
|
|
169
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
170
|
+
return { content: [{ type: 'text', text: `Task ${session.taskId} error: ${msg}` }], isError: true };
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
// ============================================================================
|
|
174
|
+
// Tool: get_task
|
|
175
|
+
// ============================================================================
|
|
176
|
+
server.tool('get_task', 'Get the current status of a task by ID, including artifact content if completed', {
|
|
177
|
+
taskId: z.string().describe('The task ID to look up'),
|
|
178
|
+
}, async ({ taskId }) => {
|
|
179
|
+
const freeClient = await getTaskClient('free');
|
|
180
|
+
const task = await freeClient.getTask(taskId);
|
|
181
|
+
const output = [JSON.stringify(task, null, 2)];
|
|
182
|
+
const terminalStates = new Set(['completed', 'failed', 'canceled']);
|
|
183
|
+
if (task.state && terminalStates.has(task.state)) {
|
|
184
|
+
try {
|
|
185
|
+
const billingMode = resolveBillingMode(task);
|
|
186
|
+
const client = await getTaskClient(billingMode);
|
|
187
|
+
const session = await client.connect({ taskId });
|
|
188
|
+
const allArtifacts = session.listArtifacts();
|
|
189
|
+
for (const ref of allArtifacts) {
|
|
190
|
+
try {
|
|
191
|
+
const downloaded = await session.downloadArtifact(ref);
|
|
192
|
+
const isText = downloaded.mimeType.startsWith('text/') ||
|
|
193
|
+
downloaded.mimeType === 'application/json';
|
|
194
|
+
if (isText) {
|
|
195
|
+
const text = new TextDecoder().decode(downloaded.data);
|
|
196
|
+
output.push(`[artifact: ${downloaded.fileName ?? 'unnamed'}]\n${text}`);
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
output.push(`[artifact: ${downloaded.fileName ?? 'unnamed'}] (${downloaded.mimeType}, ${downloaded.data.length} bytes)`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
output.push(`[artifact: ${ref.fileName ?? 'unnamed'}] (download failed)`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
session.close();
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
// connect failed — return task info without artifacts
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return { content: [{ type: 'text', text: output.join('\n') }] };
|
|
213
|
+
});
|
|
214
|
+
// ============================================================================
|
|
215
|
+
// Tool: list_tasks
|
|
216
|
+
// ============================================================================
|
|
217
|
+
server.tool('list_tasks', 'List tasks, optionally filtered by agent or state', {
|
|
218
|
+
agentName: z.string().optional().describe('Filter by agent name'),
|
|
219
|
+
state: z.string().optional().describe('Filter by state (e.g. completed, failed, running)'),
|
|
220
|
+
limit: z.number().optional().describe('Max results to return'),
|
|
221
|
+
}, async ({ agentName, state, limit }) => {
|
|
222
|
+
const client = await getTaskClient();
|
|
223
|
+
const result = await client.listTasks({ agentName, state, limit });
|
|
224
|
+
const lines = result.tasks.map((t) => `${t.taskId} | ${t.agentName ?? '?'} | ${t.state ?? '?'} | ${t.createdTime ?? ''}`);
|
|
225
|
+
const header = `Tasks (${result.totalCount ?? result.tasks.length} total):`;
|
|
226
|
+
return { content: [{ type: 'text', text: [header, ...lines].join('\n') }] };
|
|
227
|
+
});
|
|
228
|
+
// ============================================================================
|
|
229
|
+
// Tool: cancel_task
|
|
230
|
+
// ============================================================================
|
|
231
|
+
server.tool('cancel_task', 'Cancel a running task', {
|
|
232
|
+
taskId: z.string().describe('The task ID to cancel'),
|
|
233
|
+
}, async ({ taskId }) => {
|
|
234
|
+
const client = await getTaskClient();
|
|
235
|
+
await client.cancelTask(taskId);
|
|
236
|
+
return { content: [{ type: 'text', text: `Task ${taskId} cancelled.` }] };
|
|
237
|
+
});
|
|
238
|
+
// ============================================================================
|
|
239
|
+
// Tool: list_agents
|
|
240
|
+
// ============================================================================
|
|
241
|
+
server.tool('list_agents', 'List available agents in the Blocks Network registry. Use listing="private" with an API key to discover your private agents.', {
|
|
242
|
+
skill: z.string().optional().describe('Filter by skill name'),
|
|
243
|
+
listing: z.enum(['public', 'private']).optional().describe('Filter by listing visibility (default: public)'),
|
|
244
|
+
limit: z.number().optional().describe('Max results to return'),
|
|
245
|
+
}, async ({ skill, listing, limit }) => {
|
|
246
|
+
const baseUrl = await getBaseUrl();
|
|
247
|
+
const apiKey = process.env.BLOCKS_API_KEY;
|
|
248
|
+
const result = await listAgentsAuthenticated({ baseUrl, apiKey, skill, listing, limit });
|
|
249
|
+
const lines = result.agents.map((a) => {
|
|
250
|
+
const skills = a.skills?.map((s) => s.name).join(', ') ?? '';
|
|
251
|
+
return `${a.agentName} | ${a.name ?? a.agentName} | ${a.listing ?? 'public'} | ${skills}`;
|
|
252
|
+
});
|
|
253
|
+
const header = `Agents (${result.totalCount ?? result.agents.length}):`;
|
|
254
|
+
return { content: [{ type: 'text', text: [header, ...lines].join('\n') }] };
|
|
255
|
+
});
|
|
256
|
+
// ============================================================================
|
|
257
|
+
// Tool: get_agent_card
|
|
258
|
+
// ============================================================================
|
|
259
|
+
server.tool('get_agent_card', 'Get the full agent card for a specific agent', {
|
|
260
|
+
agentName: z.string().describe('Agent name to look up'),
|
|
261
|
+
}, async ({ agentName }) => {
|
|
262
|
+
const baseUrl = await getBaseUrl();
|
|
263
|
+
const apiKey = process.env.BLOCKS_API_KEY;
|
|
264
|
+
const entry = await getAgent(agentName, { baseUrl, apiKey });
|
|
265
|
+
if (!entry) {
|
|
266
|
+
return { content: [{ type: 'text', text: `Agent "${agentName}" not found.` }], isError: true };
|
|
267
|
+
}
|
|
268
|
+
return { content: [{ type: 'text', text: JSON.stringify(entry.card ?? entry, null, 2) }] };
|
|
269
|
+
});
|
|
270
|
+
// ============================================================================
|
|
271
|
+
// Tool: connect_task
|
|
272
|
+
// ============================================================================
|
|
273
|
+
server.tool('connect_task', 'Connect to an existing task, stream events and data until completion', {
|
|
274
|
+
taskId: z.string().describe('The task ID to connect to'),
|
|
275
|
+
timeoutMs: z.number().optional().describe('Timeout in milliseconds (default: 60000)'),
|
|
276
|
+
}, async ({ taskId, timeoutMs }) => {
|
|
277
|
+
const freeClient = await getTaskClient('free');
|
|
278
|
+
const task = await freeClient.getTask(taskId);
|
|
279
|
+
const billingMode = resolveBillingMode(task);
|
|
280
|
+
const client = await getTaskClient(billingMode);
|
|
281
|
+
const session = await client.connect({ taskId });
|
|
282
|
+
const timeout = timeoutMs ?? 60000;
|
|
283
|
+
const results = [];
|
|
284
|
+
const streamOutputs = [];
|
|
285
|
+
const streamDrains = [];
|
|
286
|
+
session.onProgress((event) => {
|
|
287
|
+
if (event.message) {
|
|
288
|
+
results.push(`[progress] ${event.message}`);
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
const drainStream = (streamRef) => {
|
|
292
|
+
const dir = streamRef.descriptor.localDirection;
|
|
293
|
+
if (dir !== 'inbound' && dir !== 'bidirectional')
|
|
294
|
+
return;
|
|
295
|
+
const format = streamRef.descriptor.format;
|
|
296
|
+
const label = streamRef.descriptor.declaredStream ?? streamRef.descriptor.streamId;
|
|
297
|
+
const entry = { label, chunks: [] };
|
|
298
|
+
streamOutputs.push(entry);
|
|
299
|
+
try {
|
|
300
|
+
const streamClient = streamRef.open();
|
|
301
|
+
const drain = (async () => {
|
|
302
|
+
if (format === 'events') {
|
|
303
|
+
for await (const event of streamClient.events()) {
|
|
304
|
+
entry.chunks.push(JSON.stringify(event) + '\n');
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
else {
|
|
308
|
+
const decoder = new TextDecoder();
|
|
309
|
+
for await (const chunk of streamClient.bytes()) {
|
|
310
|
+
entry.chunks.push(decoder.decode(chunk, { stream: true }));
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
})();
|
|
314
|
+
streamDrains.push(drain.catch(() => { }));
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
// stream already ended or terminal
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
for (const ref of session.listStreams()) {
|
|
321
|
+
drainStream(ref);
|
|
322
|
+
}
|
|
323
|
+
session.onStream(drainStream);
|
|
324
|
+
try {
|
|
325
|
+
const terminal = await session.waitForTerminal(timeout);
|
|
326
|
+
await Promise.allSettled(streamDrains);
|
|
327
|
+
const allArtifacts = session.listArtifacts();
|
|
328
|
+
results.unshift(`Task ${taskId} ${terminal.state}`);
|
|
329
|
+
for (const s of streamOutputs) {
|
|
330
|
+
if (s.chunks.length > 0) {
|
|
331
|
+
results.push(`[stream: ${s.label}]\n${s.chunks.join('')}`);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
for (const ref of allArtifacts) {
|
|
335
|
+
try {
|
|
336
|
+
const downloaded = await session.downloadArtifact(ref);
|
|
337
|
+
const isText = downloaded.mimeType.startsWith('text/') ||
|
|
338
|
+
downloaded.mimeType === 'application/json';
|
|
339
|
+
if (isText) {
|
|
340
|
+
const text = new TextDecoder().decode(downloaded.data);
|
|
341
|
+
results.push(`[artifact: ${downloaded.fileName ?? 'unnamed'}]\n${text}`);
|
|
342
|
+
}
|
|
343
|
+
else {
|
|
344
|
+
results.push(`[artifact: ${downloaded.fileName ?? 'unnamed'}] (${downloaded.mimeType}, ${downloaded.data.length} bytes)`);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
catch {
|
|
348
|
+
results.push(`[artifact: ${ref.fileName ?? 'unnamed'}] (download failed)`);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
if (terminal.state === 'failed') {
|
|
352
|
+
results.push(`Error: ${terminal.error ?? terminal.reason ?? 'unknown'}`);
|
|
353
|
+
}
|
|
354
|
+
await session.asyncClose();
|
|
355
|
+
return { content: [{ type: 'text', text: results.join('\n') }] };
|
|
356
|
+
}
|
|
357
|
+
catch (err) {
|
|
358
|
+
await session.asyncClose();
|
|
359
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
360
|
+
const partial = [`Task ${taskId} timed out (${msg})`];
|
|
361
|
+
for (const s of streamOutputs) {
|
|
362
|
+
if (s.chunks.length > 0) {
|
|
363
|
+
partial.push(`[stream: ${s.label}]\n${s.chunks.join('')}`);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
partial.push(...results);
|
|
367
|
+
return { content: [{ type: 'text', text: partial.join('\n') }], isError: true };
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
// ============================================================================
|
|
371
|
+
// Start server
|
|
372
|
+
// ============================================================================
|
|
373
|
+
function shutdown() {
|
|
374
|
+
for (const client of taskClients.values()) {
|
|
375
|
+
client.destroy();
|
|
376
|
+
}
|
|
377
|
+
taskClients.clear();
|
|
378
|
+
}
|
|
379
|
+
async function main() {
|
|
380
|
+
const transport = new StdioServerTransport();
|
|
381
|
+
process.on('SIGINT', shutdown);
|
|
382
|
+
process.on('SIGTERM', shutdown);
|
|
383
|
+
transport.onclose = shutdown;
|
|
384
|
+
await server.connect(transport);
|
|
385
|
+
}
|
|
386
|
+
main().catch((err) => {
|
|
387
|
+
console.error('Fatal:', err);
|
|
388
|
+
shutdown();
|
|
389
|
+
process.exit(1);
|
|
390
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@blocks-network/mcp-server",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "MCP server for Blocks Network consumer operations",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"blocks-mcp": "dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build:sdk": "cd ../sdks/node && npm install --ignore-scripts && ../../mcp/node_modules/.bin/tsc",
|
|
12
|
+
"prebuild": "npm run build:sdk",
|
|
13
|
+
"build": "tsc",
|
|
14
|
+
"start": "node dist/index.js",
|
|
15
|
+
"dev": "tsx src/index.ts"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@blocks-network/sdk": "file:../sdks/node",
|
|
19
|
+
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
20
|
+
"zod": "^3.25.1"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@types/node": "^22.0.0",
|
|
24
|
+
"tsx": "^4.19.0",
|
|
25
|
+
"typescript": "^5.7.0"
|
|
26
|
+
},
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=20.0.0"
|
|
29
|
+
}
|
|
30
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { resolve, relative } from 'node:path';
|
|
4
|
+
import { realpathSync, statSync } from 'node:fs';
|
|
5
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
6
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
import {
|
|
9
|
+
TaskClient,
|
|
10
|
+
textPart,
|
|
11
|
+
filePartFromPath,
|
|
12
|
+
fetchCdmConfig,
|
|
13
|
+
getAgent,
|
|
14
|
+
BLOCKS_MAX_UPLOAD_BYTES,
|
|
15
|
+
type TaskInfo,
|
|
16
|
+
type SendMessageRequestPart,
|
|
17
|
+
} from '@blocks-network/sdk';
|
|
18
|
+
|
|
19
|
+
const ALLOWED_ROOT = process.env.BLOCKS_MCP_FILE_ROOT ?? process.cwd();
|
|
20
|
+
|
|
21
|
+
if (!process.env.BLOCKS_API_KEY) {
|
|
22
|
+
console.error('Warning: BLOCKS_API_KEY is not set. Private agents and paid tasks will not work.');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function validateFilePath(filePath: string): string {
|
|
26
|
+
const resolved = resolve(filePath);
|
|
27
|
+
let real: string;
|
|
28
|
+
try {
|
|
29
|
+
real = realpathSync(resolved);
|
|
30
|
+
} catch {
|
|
31
|
+
throw new Error(`File not found: ${filePath}`);
|
|
32
|
+
}
|
|
33
|
+
const root = realpathSync(resolve(ALLOWED_ROOT));
|
|
34
|
+
const rel = relative(root, real);
|
|
35
|
+
if (rel.startsWith('..') || resolve(root, rel) !== real) {
|
|
36
|
+
throw new Error(`File path must be within ${ALLOWED_ROOT}`);
|
|
37
|
+
}
|
|
38
|
+
return real;
|
|
39
|
+
}
|
|
40
|
+
|
|
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
|
+
const taskClients = new Map<string, TaskClient>();
|
|
48
|
+
let resolvedBaseUrl: string | undefined;
|
|
49
|
+
|
|
50
|
+
async function getBaseUrl(): Promise<string> {
|
|
51
|
+
if (resolvedBaseUrl) return resolvedBaseUrl;
|
|
52
|
+
if (process.env.BLOCKS_BACKEND_URL) {
|
|
53
|
+
resolvedBaseUrl = process.env.BLOCKS_BACKEND_URL;
|
|
54
|
+
return resolvedBaseUrl;
|
|
55
|
+
}
|
|
56
|
+
const cdm = await fetchCdmConfig(process.env.BLOCKS_CDM_URL);
|
|
57
|
+
resolvedBaseUrl = cdm.api.baseUrl;
|
|
58
|
+
return resolvedBaseUrl;
|
|
59
|
+
}
|
|
60
|
+
|
|
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> {
|
|
96
|
+
let client = taskClients.get(billingMode);
|
|
97
|
+
if (!client) {
|
|
98
|
+
const apiKey = process.env.BLOCKS_API_KEY;
|
|
99
|
+
client = await TaskClient.create({ apiKey, billingMode });
|
|
100
|
+
taskClients.set(billingMode, client);
|
|
101
|
+
}
|
|
102
|
+
return client;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const server = new McpServer({
|
|
106
|
+
name: 'blocks-network',
|
|
107
|
+
version: '0.1.0',
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// ============================================================================
|
|
111
|
+
// Tool: send_task
|
|
112
|
+
// ============================================================================
|
|
113
|
+
|
|
114
|
+
server.tool(
|
|
115
|
+
'send_task',
|
|
116
|
+
'Send a task to a Blocks Network agent and wait for the result',
|
|
117
|
+
{
|
|
118
|
+
agentName: z.string().describe('Name of the target agent'),
|
|
119
|
+
message: z.string().describe('Text message to send to the agent'),
|
|
120
|
+
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'),
|
|
122
|
+
taskKind: z.enum(['request', 'pipe']).optional().describe('Task kind (default: request)'),
|
|
123
|
+
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
|
+
}
|
|
210
|
+
},
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
// ============================================================================
|
|
214
|
+
// Tool: get_task
|
|
215
|
+
// ============================================================================
|
|
216
|
+
|
|
217
|
+
server.tool(
|
|
218
|
+
'get_task',
|
|
219
|
+
'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
|
+
},
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
// ============================================================================
|
|
261
|
+
// Tool: list_tasks
|
|
262
|
+
// ============================================================================
|
|
263
|
+
|
|
264
|
+
server.tool(
|
|
265
|
+
'list_tasks',
|
|
266
|
+
'List tasks, optionally filtered by agent or state',
|
|
267
|
+
{
|
|
268
|
+
agentName: z.string().optional().describe('Filter by agent name'),
|
|
269
|
+
state: z.string().optional().describe('Filter by state (e.g. completed, failed, running)'),
|
|
270
|
+
limit: z.number().optional().describe('Max results to return'),
|
|
271
|
+
},
|
|
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
|
+
},
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
// ============================================================================
|
|
284
|
+
// Tool: cancel_task
|
|
285
|
+
// ============================================================================
|
|
286
|
+
|
|
287
|
+
server.tool(
|
|
288
|
+
'cancel_task',
|
|
289
|
+
'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
|
+
},
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
// ============================================================================
|
|
301
|
+
// Tool: list_agents
|
|
302
|
+
// ============================================================================
|
|
303
|
+
|
|
304
|
+
server.tool(
|
|
305
|
+
'list_agents',
|
|
306
|
+
'List available agents in the Blocks Network registry. Use listing="private" with an API key to discover your private agents.',
|
|
307
|
+
{
|
|
308
|
+
skill: z.string().optional().describe('Filter by skill name'),
|
|
309
|
+
listing: z.enum(['public', 'private']).optional().describe('Filter by listing visibility (default: public)'),
|
|
310
|
+
limit: z.number().optional().describe('Max results to return'),
|
|
311
|
+
},
|
|
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
|
+
},
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
// ============================================================================
|
|
327
|
+
// Tool: get_agent_card
|
|
328
|
+
// ============================================================================
|
|
329
|
+
|
|
330
|
+
server.tool(
|
|
331
|
+
'get_agent_card',
|
|
332
|
+
'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
|
+
},
|
|
345
|
+
);
|
|
346
|
+
|
|
347
|
+
// ============================================================================
|
|
348
|
+
// Tool: connect_task
|
|
349
|
+
// ============================================================================
|
|
350
|
+
|
|
351
|
+
server.tool(
|
|
352
|
+
'connect_task',
|
|
353
|
+
'Connect to an existing task, stream events and data until completion',
|
|
354
|
+
{
|
|
355
|
+
taskId: z.string().describe('The task ID to connect to'),
|
|
356
|
+
timeoutMs: z.number().optional().describe('Timeout in milliseconds (default: 60000)'),
|
|
357
|
+
},
|
|
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
|
+
},
|
|
454
|
+
);
|
|
455
|
+
|
|
456
|
+
// ============================================================================
|
|
457
|
+
// Start server
|
|
458
|
+
// ============================================================================
|
|
459
|
+
|
|
460
|
+
function shutdown() {
|
|
461
|
+
for (const client of taskClients.values()) {
|
|
462
|
+
client.destroy();
|
|
463
|
+
}
|
|
464
|
+
taskClients.clear();
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
async function main() {
|
|
468
|
+
const transport = new StdioServerTransport();
|
|
469
|
+
process.on('SIGINT', shutdown);
|
|
470
|
+
process.on('SIGTERM', shutdown);
|
|
471
|
+
transport.onclose = shutdown;
|
|
472
|
+
await server.connect(transport);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
main().catch((err) => {
|
|
476
|
+
console.error('Fatal:', err);
|
|
477
|
+
shutdown();
|
|
478
|
+
process.exit(1);
|
|
479
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"outDir": "dist",
|
|
7
|
+
"rootDir": "src",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"declaration": true,
|
|
12
|
+
"resolveJsonModule": true
|
|
13
|
+
},
|
|
14
|
+
"include": ["src/**/*"]
|
|
15
|
+
}
|