@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/dist/tools.d.ts
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { textPart, filePartFromPath, type TaskInfo, type SendMessageRequestPart } from '@blocks-network/sdk';
|
|
9
9
|
import type { ListAgentsResult } from './registry-list.js';
|
|
10
|
+
import { type AgentStatusResponse } from './agent-status.js';
|
|
11
|
+
import type { ConsumerBalance, TopUpSession } from './billing.js';
|
|
10
12
|
export interface ArtifactRef {
|
|
11
13
|
fileName?: string;
|
|
12
14
|
}
|
|
@@ -65,6 +67,9 @@ export interface TaskClientLike {
|
|
|
65
67
|
totalCount?: number;
|
|
66
68
|
}>;
|
|
67
69
|
cancelTask(taskId: string): Promise<unknown>;
|
|
70
|
+
pauseTask(taskId: string): Promise<unknown>;
|
|
71
|
+
resumeTask(taskId: string): Promise<unknown>;
|
|
72
|
+
retryTask(taskId: string): Promise<unknown>;
|
|
68
73
|
connect(params: {
|
|
69
74
|
taskId: string;
|
|
70
75
|
}): Promise<TaskSessionLike>;
|
|
@@ -100,15 +105,37 @@ export interface ToolDeps {
|
|
|
100
105
|
listAgents(options: {
|
|
101
106
|
baseUrl: string;
|
|
102
107
|
apiKey?: string;
|
|
103
|
-
|
|
108
|
+
/** Free-text search query (`q`); matches agent name, description, tags, etc. */
|
|
109
|
+
q?: string;
|
|
110
|
+
tag?: string;
|
|
104
111
|
listing?: 'public' | 'private';
|
|
105
|
-
|
|
112
|
+
/** Optional cap on the total number of agents to fetch across all pages. */
|
|
113
|
+
maxAgents?: number;
|
|
106
114
|
}): Promise<ListAgentsResult>;
|
|
107
115
|
validateFilePath(filePath: string): string;
|
|
116
|
+
resolveSavePath(filePath: string): string;
|
|
117
|
+
writeFile(filePath: string, data: Uint8Array): void;
|
|
108
118
|
fileSize(path: string): number;
|
|
109
119
|
maxUploadBytes: number;
|
|
110
120
|
filePartFromPath: typeof filePartFromPath;
|
|
111
121
|
textPart: typeof textPart;
|
|
122
|
+
getOrgId(): string | undefined;
|
|
123
|
+
fetchAgentStatus(options: {
|
|
124
|
+
baseUrl: string;
|
|
125
|
+
apiKey?: string;
|
|
126
|
+
agentNames: string[];
|
|
127
|
+
}): Promise<AgentStatusResponse>;
|
|
128
|
+
getConsumerBalance(options: {
|
|
129
|
+
baseUrl: string;
|
|
130
|
+
apiKey: string;
|
|
131
|
+
orgId: string;
|
|
132
|
+
}): Promise<ConsumerBalance>;
|
|
133
|
+
createConsumerTopUp(options: {
|
|
134
|
+
baseUrl: string;
|
|
135
|
+
apiKey: string;
|
|
136
|
+
orgId: string;
|
|
137
|
+
amountUsd: number;
|
|
138
|
+
}): Promise<TopUpSession>;
|
|
112
139
|
}
|
|
113
140
|
export interface SendTaskParams {
|
|
114
141
|
agentName: string;
|
|
@@ -131,9 +158,28 @@ export interface CancelTaskParams {
|
|
|
131
158
|
taskId: string;
|
|
132
159
|
}
|
|
133
160
|
export interface ListAgentsParams {
|
|
134
|
-
|
|
161
|
+
tag?: string;
|
|
162
|
+
listing?: 'public' | 'private';
|
|
163
|
+
limit?: number;
|
|
164
|
+
/**
|
|
165
|
+
* Include agents that have no online instances. Defaults to false: the
|
|
166
|
+
* registry returns every registered agent regardless of whether any
|
|
167
|
+
* instance is currently running, so by default we filter to agents with
|
|
168
|
+
* at least one online instance to avoid surfacing unreachable agents.
|
|
169
|
+
*/
|
|
170
|
+
includeOffline?: boolean;
|
|
171
|
+
}
|
|
172
|
+
export interface SearchAgentsParams {
|
|
173
|
+
/** Free-text search query; matches agent name, description, tags, etc. */
|
|
174
|
+
query: string;
|
|
175
|
+
tag?: string;
|
|
135
176
|
listing?: 'public' | 'private';
|
|
136
177
|
limit?: number;
|
|
178
|
+
/**
|
|
179
|
+
* Include agents that have no online instances. Defaults to false so we
|
|
180
|
+
* only surface agents that can actually take a task. See ListAgentsParams.
|
|
181
|
+
*/
|
|
182
|
+
includeOffline?: boolean;
|
|
137
183
|
}
|
|
138
184
|
export interface GetAgentCardParams {
|
|
139
185
|
agentName: string;
|
|
@@ -142,11 +188,33 @@ export interface ConnectTaskParams {
|
|
|
142
188
|
taskId: string;
|
|
143
189
|
timeoutMs?: number;
|
|
144
190
|
}
|
|
191
|
+
export interface DownloadArtifactParams {
|
|
192
|
+
taskId: string;
|
|
193
|
+
fileName: string;
|
|
194
|
+
savePath?: string;
|
|
195
|
+
}
|
|
196
|
+
export interface TaskIdParams {
|
|
197
|
+
taskId: string;
|
|
198
|
+
}
|
|
199
|
+
export interface GetAgentStatusParams {
|
|
200
|
+
agentNames: string[];
|
|
201
|
+
}
|
|
202
|
+
export interface RequestTopUpParams {
|
|
203
|
+
amountUsd: number;
|
|
204
|
+
}
|
|
145
205
|
export declare function sendTask(params: SendTaskParams, deps: ToolDeps): Promise<ToolResult>;
|
|
146
206
|
export declare function getTask(params: GetTaskParams, deps: ToolDeps): Promise<ToolResult>;
|
|
147
207
|
export declare function listTasks(params: ListTasksParams, deps: ToolDeps): Promise<ToolResult>;
|
|
148
208
|
export declare function cancelTask(params: CancelTaskParams, deps: ToolDeps): Promise<ToolResult>;
|
|
149
209
|
export declare function listAgents(params: ListAgentsParams, deps: ToolDeps): Promise<ToolResult>;
|
|
210
|
+
export declare function searchAgents(params: SearchAgentsParams, deps: ToolDeps): Promise<ToolResult>;
|
|
150
211
|
export declare function getAgentCard(params: GetAgentCardParams, deps: ToolDeps): Promise<ToolResult>;
|
|
151
212
|
export declare function connectTask(params: ConnectTaskParams, deps: ToolDeps): Promise<ToolResult>;
|
|
213
|
+
export declare function downloadArtifact(params: DownloadArtifactParams, deps: ToolDeps): Promise<ToolResult>;
|
|
214
|
+
export declare function pauseTask(params: TaskIdParams, deps: ToolDeps): Promise<ToolResult>;
|
|
215
|
+
export declare function resumeTask(params: TaskIdParams, deps: ToolDeps): Promise<ToolResult>;
|
|
216
|
+
export declare function retryTask(params: TaskIdParams, deps: ToolDeps): Promise<ToolResult>;
|
|
217
|
+
export declare function getAgentStatus(params: GetAgentStatusParams, deps: ToolDeps): Promise<ToolResult>;
|
|
218
|
+
export declare function checkBalance(_params: Record<string, never>, deps: ToolDeps): Promise<ToolResult>;
|
|
219
|
+
export declare function requestTopup(params: RequestTopUpParams, deps: ToolDeps): Promise<ToolResult>;
|
|
152
220
|
export declare function defaultFileSize(path: string): number;
|
package/dist/tools.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* touching the network or the filesystem.
|
|
7
7
|
*/
|
|
8
8
|
import { statSync } from 'node:fs';
|
|
9
|
+
import { MAX_AGENT_NAMES, AGENT_NAME_PATTERN, } from './agent-status.js';
|
|
9
10
|
// ============================================================================
|
|
10
11
|
// Helpers
|
|
11
12
|
// ============================================================================
|
|
@@ -161,17 +162,89 @@ export async function listAgents(params, deps) {
|
|
|
161
162
|
const result = await deps.listAgents({
|
|
162
163
|
baseUrl,
|
|
163
164
|
apiKey,
|
|
164
|
-
|
|
165
|
+
tag: params.tag,
|
|
165
166
|
listing: params.listing,
|
|
166
|
-
|
|
167
|
+
maxAgents: params.limit,
|
|
167
168
|
});
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
169
|
+
const total = result.totalCount ?? result.agents.length;
|
|
170
|
+
// The registry returns every registered agent regardless of whether any
|
|
171
|
+
// instance is currently running. Default to online-only so we never
|
|
172
|
+
// surface agents that can't actually take a task; `includeOffline` opts
|
|
173
|
+
// back into the full registry view.
|
|
174
|
+
if (params.includeOffline) {
|
|
175
|
+
const lines = result.agents.map(formatAgentRow);
|
|
176
|
+
const header = `Agents (${result.agents.length} of ${total} total):`;
|
|
177
|
+
return { content: [{ type: 'text', text: [header, ...lines].join('\n') }] };
|
|
178
|
+
}
|
|
179
|
+
const online = await filterOnlineAgents(result.agents, { baseUrl, apiKey }, deps);
|
|
180
|
+
const lines = online.map(formatAgentRow);
|
|
181
|
+
const header = `Agents (${online.length} online of ${total} total):`;
|
|
182
|
+
return { content: [{ type: 'text', text: [header, ...lines].join('\n') }] };
|
|
183
|
+
}
|
|
184
|
+
// ============================================================================
|
|
185
|
+
// search_agent
|
|
186
|
+
// ============================================================================
|
|
187
|
+
export async function searchAgents(params, deps) {
|
|
188
|
+
const query = params.query.trim();
|
|
189
|
+
if (!query) {
|
|
190
|
+
return {
|
|
191
|
+
content: [{ type: 'text', text: 'Search query must not be empty.' }],
|
|
192
|
+
isError: true,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
const baseUrl = await deps.getBaseUrl();
|
|
196
|
+
const apiKey = deps.getApiKey();
|
|
197
|
+
const result = await deps.listAgents({
|
|
198
|
+
baseUrl,
|
|
199
|
+
apiKey,
|
|
200
|
+
q: query,
|
|
201
|
+
tag: params.tag,
|
|
202
|
+
listing: params.listing,
|
|
203
|
+
maxAgents: params.limit,
|
|
171
204
|
});
|
|
172
|
-
const
|
|
205
|
+
const total = result.totalCount ?? result.agents.length;
|
|
206
|
+
// Mirror list_agents: default to online-only so we never surface agents
|
|
207
|
+
// that can't actually take a task; `includeOffline` opts back into the
|
|
208
|
+
// full set of matches.
|
|
209
|
+
if (params.includeOffline) {
|
|
210
|
+
const lines = result.agents.map(formatAgentRow);
|
|
211
|
+
const header = `Agents matching "${query}" (${result.agents.length} of ${total} total):`;
|
|
212
|
+
return { content: [{ type: 'text', text: [header, ...lines].join('\n') }] };
|
|
213
|
+
}
|
|
214
|
+
const online = await filterOnlineAgents(result.agents, { baseUrl, apiKey }, deps);
|
|
215
|
+
const lines = online.map(formatAgentRow);
|
|
216
|
+
const header = `Agents matching "${query}" (${online.length} online of ${total} total):`;
|
|
173
217
|
return { content: [{ type: 'text', text: [header, ...lines].join('\n') }] };
|
|
174
218
|
}
|
|
219
|
+
function formatAgentRow(a) {
|
|
220
|
+
const tags = a.tags?.map((t) => t.name).join(', ') ?? '';
|
|
221
|
+
return `${a.agentName} | ${a.name ?? a.agentName} | ${a.listing ?? 'public'} | ${tags}`;
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Keep only agents that have at least one online instance, as reported by
|
|
225
|
+
* the agent-status service. Agent names that the status endpoint can't
|
|
226
|
+
* accept (too many, or invalid characters) are queried in valid batches;
|
|
227
|
+
* any name that's unqueryable is treated as offline and dropped.
|
|
228
|
+
*/
|
|
229
|
+
async function filterOnlineAgents(agents, ctx, deps) {
|
|
230
|
+
const queryable = agents.filter((a) => AGENT_NAME_PATTERN.test(a.agentName));
|
|
231
|
+
if (queryable.length === 0)
|
|
232
|
+
return [];
|
|
233
|
+
const online = new Set();
|
|
234
|
+
for (let i = 0; i < queryable.length; i += MAX_AGENT_NAMES) {
|
|
235
|
+
const batch = queryable.slice(i, i + MAX_AGENT_NAMES);
|
|
236
|
+
const status = await deps.fetchAgentStatus({
|
|
237
|
+
baseUrl: ctx.baseUrl,
|
|
238
|
+
apiKey: ctx.apiKey,
|
|
239
|
+
agentNames: batch.map((a) => a.agentName),
|
|
240
|
+
});
|
|
241
|
+
for (const [name, info] of Object.entries(status.agents)) {
|
|
242
|
+
if ((info.onlineCount ?? 0) > 0)
|
|
243
|
+
online.add(name);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return agents.filter((a) => online.has(a.agentName));
|
|
247
|
+
}
|
|
175
248
|
// ============================================================================
|
|
176
249
|
// get_agent_card
|
|
177
250
|
// ============================================================================
|
|
@@ -268,6 +341,172 @@ export async function connectTask(params, deps) {
|
|
|
268
341
|
}
|
|
269
342
|
}
|
|
270
343
|
// ============================================================================
|
|
344
|
+
// download_artifact
|
|
345
|
+
// ============================================================================
|
|
346
|
+
function bytesToBase64(bytes) {
|
|
347
|
+
let binary = '';
|
|
348
|
+
for (let i = 0; i < bytes.length; i++)
|
|
349
|
+
binary += String.fromCharCode(bytes[i]);
|
|
350
|
+
return Buffer.from(binary, 'binary').toString('base64');
|
|
351
|
+
}
|
|
352
|
+
export async function downloadArtifact(params, deps) {
|
|
353
|
+
const freeClient = await deps.getTaskClient('free');
|
|
354
|
+
const task = await freeClient.getTask(params.taskId);
|
|
355
|
+
const billingMode = resolveBillingMode(task);
|
|
356
|
+
const client = await deps.getTaskClient(billingMode);
|
|
357
|
+
let session;
|
|
358
|
+
try {
|
|
359
|
+
session = await client.connect({ taskId: params.taskId });
|
|
360
|
+
}
|
|
361
|
+
catch (err) {
|
|
362
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
363
|
+
return {
|
|
364
|
+
content: [{ type: 'text', text: `Failed to connect to task ${params.taskId}: ${msg}` }],
|
|
365
|
+
isError: true,
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
try {
|
|
369
|
+
const refs = session.listArtifacts();
|
|
370
|
+
const ref = refs.find((r) => r.fileName === params.fileName);
|
|
371
|
+
if (!ref) {
|
|
372
|
+
const available = refs.map((r) => r.fileName ?? 'unnamed').join(', ') || '(none)';
|
|
373
|
+
return {
|
|
374
|
+
content: [
|
|
375
|
+
{
|
|
376
|
+
type: 'text',
|
|
377
|
+
text: `Artifact "${params.fileName}" not found on task ${params.taskId}. Available: ${available}`,
|
|
378
|
+
},
|
|
379
|
+
],
|
|
380
|
+
isError: true,
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
const downloaded = await session.downloadArtifact(ref);
|
|
384
|
+
if (params.savePath) {
|
|
385
|
+
const safePath = deps.resolveSavePath(params.savePath);
|
|
386
|
+
deps.writeFile(safePath, downloaded.data);
|
|
387
|
+
return {
|
|
388
|
+
content: [
|
|
389
|
+
{
|
|
390
|
+
type: 'text',
|
|
391
|
+
text: `Saved ${downloaded.data.length} bytes to ${safePath} (${downloaded.mimeType})`,
|
|
392
|
+
},
|
|
393
|
+
],
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
if (isTextMimeType(downloaded.mimeType)) {
|
|
397
|
+
const text = new TextDecoder().decode(downloaded.data);
|
|
398
|
+
return {
|
|
399
|
+
content: [
|
|
400
|
+
{
|
|
401
|
+
type: 'text',
|
|
402
|
+
text: `[artifact: ${downloaded.fileName ?? params.fileName}] (${downloaded.mimeType}, ${downloaded.data.length} bytes)\n${text}`,
|
|
403
|
+
},
|
|
404
|
+
],
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
return {
|
|
408
|
+
content: [
|
|
409
|
+
{
|
|
410
|
+
type: 'text',
|
|
411
|
+
text: `[artifact: ${downloaded.fileName ?? params.fileName}] (${downloaded.mimeType}, ${downloaded.data.length} bytes, base64)\n${bytesToBase64(downloaded.data)}`,
|
|
412
|
+
},
|
|
413
|
+
],
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
finally {
|
|
417
|
+
session.close();
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
// ============================================================================
|
|
421
|
+
// pause_task / resume_task / retry_task
|
|
422
|
+
// ============================================================================
|
|
423
|
+
export async function pauseTask(params, deps) {
|
|
424
|
+
const client = await deps.getTaskClient();
|
|
425
|
+
await client.pauseTask(params.taskId);
|
|
426
|
+
return { content: [{ type: 'text', text: `Task ${params.taskId} paused.` }] };
|
|
427
|
+
}
|
|
428
|
+
export async function resumeTask(params, deps) {
|
|
429
|
+
const client = await deps.getTaskClient();
|
|
430
|
+
await client.resumeTask(params.taskId);
|
|
431
|
+
return { content: [{ type: 'text', text: `Task ${params.taskId} resumed.` }] };
|
|
432
|
+
}
|
|
433
|
+
export async function retryTask(params, deps) {
|
|
434
|
+
const client = await deps.getTaskClient();
|
|
435
|
+
await client.retryTask(params.taskId);
|
|
436
|
+
return { content: [{ type: 'text', text: `Task ${params.taskId} retry requested.` }] };
|
|
437
|
+
}
|
|
438
|
+
// ============================================================================
|
|
439
|
+
// get_agent_status
|
|
440
|
+
// ============================================================================
|
|
441
|
+
export async function getAgentStatus(params, deps) {
|
|
442
|
+
const baseUrl = await deps.getBaseUrl();
|
|
443
|
+
const apiKey = deps.getApiKey();
|
|
444
|
+
const result = await deps.fetchAgentStatus({
|
|
445
|
+
baseUrl,
|
|
446
|
+
apiKey,
|
|
447
|
+
agentNames: params.agentNames,
|
|
448
|
+
});
|
|
449
|
+
return {
|
|
450
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
// ============================================================================
|
|
454
|
+
// check_balance / request_topup
|
|
455
|
+
// ============================================================================
|
|
456
|
+
function requireBillingContext(deps) {
|
|
457
|
+
const apiKey = deps.getApiKey();
|
|
458
|
+
if (!apiKey) {
|
|
459
|
+
return {
|
|
460
|
+
content: [{ type: 'text', text: 'BLOCKS_API_KEY is required for billing operations.' }],
|
|
461
|
+
isError: true,
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
const orgId = deps.getOrgId();
|
|
465
|
+
if (!orgId) {
|
|
466
|
+
return {
|
|
467
|
+
content: [
|
|
468
|
+
{
|
|
469
|
+
type: 'text',
|
|
470
|
+
text: 'BLOCKS_ORG_ID is required for billing operations. Set it from your dashboard URL or `blocks whoami --json`.',
|
|
471
|
+
},
|
|
472
|
+
],
|
|
473
|
+
isError: true,
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
return { apiKey, orgId };
|
|
477
|
+
}
|
|
478
|
+
export async function checkBalance(_params, deps) {
|
|
479
|
+
const ctx = requireBillingContext(deps);
|
|
480
|
+
if ('content' in ctx)
|
|
481
|
+
return ctx;
|
|
482
|
+
const baseUrl = await deps.getBaseUrl();
|
|
483
|
+
const balance = await deps.getConsumerBalance({
|
|
484
|
+
baseUrl,
|
|
485
|
+
apiKey: ctx.apiKey,
|
|
486
|
+
orgId: ctx.orgId,
|
|
487
|
+
});
|
|
488
|
+
return {
|
|
489
|
+
content: [{ type: 'text', text: JSON.stringify(balance, null, 2) }],
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
export async function requestTopup(params, deps) {
|
|
493
|
+
const ctx = requireBillingContext(deps);
|
|
494
|
+
if ('content' in ctx)
|
|
495
|
+
return ctx;
|
|
496
|
+
const baseUrl = await deps.getBaseUrl();
|
|
497
|
+
const session = await deps.createConsumerTopUp({
|
|
498
|
+
baseUrl,
|
|
499
|
+
apiKey: ctx.apiKey,
|
|
500
|
+
orgId: ctx.orgId,
|
|
501
|
+
amountUsd: params.amountUsd,
|
|
502
|
+
});
|
|
503
|
+
const url = session.checkoutUrl;
|
|
504
|
+
const body = url
|
|
505
|
+
? `Open this URL in a browser to complete the $${params.amountUsd.toFixed(2)} top-up:\n${url}`
|
|
506
|
+
: JSON.stringify(session, null, 2);
|
|
507
|
+
return { content: [{ type: 'text', text: body }] };
|
|
508
|
+
}
|
|
509
|
+
// ============================================================================
|
|
271
510
|
// Default file helpers (re-exported so index.ts can build a default ToolDeps)
|
|
272
511
|
// ============================================================================
|
|
273
512
|
export function defaultFileSize(path) {
|
package/package.json
CHANGED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent presence/availability helper.
|
|
3
|
+
*
|
|
4
|
+
* Calls GET /api/v1/agent-status with the Blocks-Protocol-Version
|
|
5
|
+
* header. Backend route is optionalAuth so the API key is forwarded
|
|
6
|
+
* when available but not required.
|
|
7
|
+
*
|
|
8
|
+
* Response shape mirrors the service's agent-status types.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
PROTOCOL_VERSION_HEADER,
|
|
13
|
+
CURRENT_PROTOCOL_VERSION,
|
|
14
|
+
} from './protocol-headers.js';
|
|
15
|
+
|
|
16
|
+
/** MUST stay in sync with the service's agent-status types. */
|
|
17
|
+
export const MAX_AGENT_NAMES = 50;
|
|
18
|
+
export const AGENT_NAME_PATTERN = /^[a-zA-Z0-9_]+$/;
|
|
19
|
+
|
|
20
|
+
export interface AgentInstanceStatus {
|
|
21
|
+
instanceId: string;
|
|
22
|
+
uuid: string;
|
|
23
|
+
online: true;
|
|
24
|
+
/**
|
|
25
|
+
* Reserved — backend currently returns 0 (live activity counters are not
|
|
26
|
+
* yet populated by the agent-status service). Do not use for routing or
|
|
27
|
+
* availability decisions.
|
|
28
|
+
*/
|
|
29
|
+
activeTasks: number;
|
|
30
|
+
/** Reserved — backend currently returns 0. See `activeTasks`. */
|
|
31
|
+
concurrentTasksPerInstance: number;
|
|
32
|
+
/** Reserved — backend currently returns 0. See `activeTasks`. */
|
|
33
|
+
startedAt: number;
|
|
34
|
+
sdkVersion: string | null;
|
|
35
|
+
cliVersion: string | null;
|
|
36
|
+
preferredProtocolVersion: string | null;
|
|
37
|
+
protocolVersions: string[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface AgentStatus {
|
|
41
|
+
agentName: string;
|
|
42
|
+
instances: AgentInstanceStatus[];
|
|
43
|
+
onlineCount: number;
|
|
44
|
+
/** Reserved — backend currently returns 0. See `AgentInstanceStatus.activeTasks`. */
|
|
45
|
+
totalActiveTasks: number;
|
|
46
|
+
taskCount: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface AgentStatusResponse {
|
|
50
|
+
agents: Record<string, AgentStatus>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface FetchAgentStatusOptions {
|
|
54
|
+
baseUrl: string;
|
|
55
|
+
agentNames: string[];
|
|
56
|
+
apiKey?: string;
|
|
57
|
+
fetchImpl?: typeof fetch;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function fetchAgentStatus(
|
|
61
|
+
opts: FetchAgentStatusOptions,
|
|
62
|
+
): Promise<AgentStatusResponse> {
|
|
63
|
+
const names = opts.agentNames.map((n) => n.trim()).filter(Boolean);
|
|
64
|
+
if (names.length === 0) {
|
|
65
|
+
throw new Error('agentNames must contain at least one value');
|
|
66
|
+
}
|
|
67
|
+
if (names.length > MAX_AGENT_NAMES) {
|
|
68
|
+
throw new Error(`agentNames must be at most ${MAX_AGENT_NAMES} values`);
|
|
69
|
+
}
|
|
70
|
+
for (const n of names) {
|
|
71
|
+
if (!AGENT_NAME_PATTERN.test(n)) {
|
|
72
|
+
throw new Error(`Invalid agent name: ${n}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const params = new URLSearchParams({ agentNames: names.join(',') });
|
|
77
|
+
const url = `${opts.baseUrl.replace(/\/+$/, '')}/api/v1/agent-status?${params}`;
|
|
78
|
+
const headers: Record<string, string> = {
|
|
79
|
+
[PROTOCOL_VERSION_HEADER]: CURRENT_PROTOCOL_VERSION,
|
|
80
|
+
};
|
|
81
|
+
if (opts.apiKey) headers['Authorization'] = `Bearer ${opts.apiKey}`;
|
|
82
|
+
|
|
83
|
+
const fetchFn = opts.fetchImpl ?? fetch;
|
|
84
|
+
const response = await fetchFn(url, { headers });
|
|
85
|
+
if (!response.ok) {
|
|
86
|
+
throw new Error(`Agent status failed: HTTP ${response.status}`);
|
|
87
|
+
}
|
|
88
|
+
return (await response.json()) as AgentStatusResponse;
|
|
89
|
+
}
|
package/src/billing.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Billing helpers — direct HTTP to the routes that accept Bearer
|
|
3
|
+
* API-key auth (`requireAuth` middleware path on the backend).
|
|
4
|
+
*
|
|
5
|
+
* GET /api/v1/billing/:orgId/consumer/balance
|
|
6
|
+
* POST /api/v1/billing/:orgId/consumer/topup
|
|
7
|
+
*
|
|
8
|
+
* Other billing routes (ledger, usage-summary, dashboard-summary,
|
|
9
|
+
* topup-from-earnings) are session-only on the backend and are not
|
|
10
|
+
* callable from an MCP server holding only an API key.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
PROTOCOL_VERSION_HEADER,
|
|
15
|
+
CURRENT_PROTOCOL_VERSION,
|
|
16
|
+
} from './protocol-headers.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Platform minimum top-up amount in USD. MUST stay in sync with
|
|
20
|
+
* the service's MIN_BILLING_AMOUNT
|
|
21
|
+
* (decimal-dollar string `'5'`). The backend rejects values below this floor
|
|
22
|
+
* via `stripeMoneyAtLeast(MIN_BILLING_AMOUNT, ...)`; we mirror it here so MCP
|
|
23
|
+
* callers get the real contract up front instead of a runtime HTTP 400.
|
|
24
|
+
*/
|
|
25
|
+
export const MIN_TOPUP_AMOUNT_USD = 5;
|
|
26
|
+
|
|
27
|
+
export interface ConsumerBalance {
|
|
28
|
+
/** Ledger balance as a decimal-dollar string (e.g. "12.34"). */
|
|
29
|
+
balance: string;
|
|
30
|
+
/** Currently reserved (held for in-flight tasks). */
|
|
31
|
+
reservedBalance: string;
|
|
32
|
+
/** balance - reservedBalance. */
|
|
33
|
+
availableBalance: string;
|
|
34
|
+
/** ISO timestamp of when the balance snapshot was taken. */
|
|
35
|
+
updatedAt: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface TopUpSession {
|
|
39
|
+
/** Stripe Checkout URL the user opens in a browser to complete payment. */
|
|
40
|
+
checkoutUrl: string;
|
|
41
|
+
/** Stripe Checkout session id. */
|
|
42
|
+
sessionId: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface BillingClientBase {
|
|
46
|
+
baseUrl: string;
|
|
47
|
+
orgId: string;
|
|
48
|
+
apiKey: string;
|
|
49
|
+
fetchImpl?: typeof fetch;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function buildHeaders(apiKey: string): Record<string, string> {
|
|
53
|
+
return {
|
|
54
|
+
[PROTOCOL_VERSION_HEADER]: CURRENT_PROTOCOL_VERSION,
|
|
55
|
+
Authorization: `Bearer ${apiKey}`,
|
|
56
|
+
'Content-Type': 'application/json',
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function billingUrl(baseUrl: string, orgId: string, suffix: string): string {
|
|
61
|
+
return `${baseUrl.replace(/\/+$/, '')}/api/v1/billing/${encodeURIComponent(orgId)}${suffix}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function getConsumerBalance(
|
|
65
|
+
opts: BillingClientBase,
|
|
66
|
+
): Promise<ConsumerBalance> {
|
|
67
|
+
const url = billingUrl(opts.baseUrl, opts.orgId, '/consumer/balance');
|
|
68
|
+
const fetchFn = opts.fetchImpl ?? fetch;
|
|
69
|
+
const response = await fetchFn(url, {
|
|
70
|
+
method: 'GET',
|
|
71
|
+
headers: buildHeaders(opts.apiKey),
|
|
72
|
+
});
|
|
73
|
+
if (!response.ok) {
|
|
74
|
+
throw new Error(`Balance lookup failed: HTTP ${response.status}`);
|
|
75
|
+
}
|
|
76
|
+
return (await response.json()) as ConsumerBalance;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface CreateTopUpOptions extends BillingClientBase {
|
|
80
|
+
/**
|
|
81
|
+
* Whole-dollar (or whole-cent decimal) USD amount, e.g. 25 or 19.99.
|
|
82
|
+
* Must be at least `MIN_TOPUP_AMOUNT_USD` ($5) — backend rejects below this.
|
|
83
|
+
*/
|
|
84
|
+
amountUsd: number;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function createConsumerTopUp(
|
|
88
|
+
opts: CreateTopUpOptions,
|
|
89
|
+
): Promise<TopUpSession> {
|
|
90
|
+
if (!Number.isFinite(opts.amountUsd) || opts.amountUsd <= 0) {
|
|
91
|
+
throw new Error('amountUsd must be a positive finite number');
|
|
92
|
+
}
|
|
93
|
+
if (Math.round(opts.amountUsd * 100) / 100 !== opts.amountUsd) {
|
|
94
|
+
throw new Error('amountUsd must be a whole-cent value (no sub-cent fractions)');
|
|
95
|
+
}
|
|
96
|
+
if (opts.amountUsd < MIN_TOPUP_AMOUNT_USD) {
|
|
97
|
+
throw new Error(
|
|
98
|
+
`amountUsd must be at least $${MIN_TOPUP_AMOUNT_USD}.00 (platform minimum)`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
const amount = opts.amountUsd.toFixed(2);
|
|
102
|
+
|
|
103
|
+
const url = billingUrl(opts.baseUrl, opts.orgId, '/consumer/topup');
|
|
104
|
+
const fetchFn = opts.fetchImpl ?? fetch;
|
|
105
|
+
const response = await fetchFn(url, {
|
|
106
|
+
method: 'POST',
|
|
107
|
+
headers: buildHeaders(opts.apiKey),
|
|
108
|
+
body: JSON.stringify({ amount }),
|
|
109
|
+
});
|
|
110
|
+
if (!response.ok) {
|
|
111
|
+
let detail = '';
|
|
112
|
+
try {
|
|
113
|
+
detail = await response.text();
|
|
114
|
+
} catch {
|
|
115
|
+
// ignore body decode failures
|
|
116
|
+
}
|
|
117
|
+
throw new Error(
|
|
118
|
+
`Top-up failed: HTTP ${response.status}${detail ? ` — ${detail}` : ''}`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
return (await response.json()) as TopUpSession;
|
|
122
|
+
}
|