@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/tools.ts
CHANGED
|
@@ -15,6 +15,12 @@ import {
|
|
|
15
15
|
} from '@blocks-network/sdk';
|
|
16
16
|
|
|
17
17
|
import type { ListAgentsResult } from './registry-list.js';
|
|
18
|
+
import {
|
|
19
|
+
MAX_AGENT_NAMES,
|
|
20
|
+
AGENT_NAME_PATTERN,
|
|
21
|
+
type AgentStatusResponse,
|
|
22
|
+
} from './agent-status.js';
|
|
23
|
+
import type { ConsumerBalance, TopUpSession } from './billing.js';
|
|
18
24
|
|
|
19
25
|
// ============================================================================
|
|
20
26
|
// Types — minimal local shapes mirroring @blocks-network/sdk surfaces.
|
|
@@ -85,6 +91,9 @@ export interface TaskClientLike {
|
|
|
85
91
|
limit?: number;
|
|
86
92
|
}): Promise<{ tasks: TaskInfo[]; totalCount?: number }>;
|
|
87
93
|
cancelTask(taskId: string): Promise<unknown>;
|
|
94
|
+
pauseTask(taskId: string): Promise<unknown>;
|
|
95
|
+
resumeTask(taskId: string): Promise<unknown>;
|
|
96
|
+
retryTask(taskId: string): Promise<unknown>;
|
|
88
97
|
connect(params: { taskId: string }): Promise<TaskSessionLike>;
|
|
89
98
|
}
|
|
90
99
|
|
|
@@ -115,15 +124,37 @@ export interface ToolDeps {
|
|
|
115
124
|
listAgents(options: {
|
|
116
125
|
baseUrl: string;
|
|
117
126
|
apiKey?: string;
|
|
118
|
-
|
|
127
|
+
/** Free-text search query (`q`); matches agent name, description, tags, etc. */
|
|
128
|
+
q?: string;
|
|
129
|
+
tag?: string;
|
|
119
130
|
listing?: 'public' | 'private';
|
|
120
|
-
|
|
131
|
+
/** Optional cap on the total number of agents to fetch across all pages. */
|
|
132
|
+
maxAgents?: number;
|
|
121
133
|
}): Promise<ListAgentsResult>;
|
|
122
134
|
validateFilePath(filePath: string): string;
|
|
135
|
+
resolveSavePath(filePath: string): string;
|
|
136
|
+
writeFile(filePath: string, data: Uint8Array): void;
|
|
123
137
|
fileSize(path: string): number;
|
|
124
138
|
maxUploadBytes: number;
|
|
125
139
|
filePartFromPath: typeof filePartFromPath;
|
|
126
140
|
textPart: typeof textPart;
|
|
141
|
+
getOrgId(): string | undefined;
|
|
142
|
+
fetchAgentStatus(options: {
|
|
143
|
+
baseUrl: string;
|
|
144
|
+
apiKey?: string;
|
|
145
|
+
agentNames: string[];
|
|
146
|
+
}): Promise<AgentStatusResponse>;
|
|
147
|
+
getConsumerBalance(options: {
|
|
148
|
+
baseUrl: string;
|
|
149
|
+
apiKey: string;
|
|
150
|
+
orgId: string;
|
|
151
|
+
}): Promise<ConsumerBalance>;
|
|
152
|
+
createConsumerTopUp(options: {
|
|
153
|
+
baseUrl: string;
|
|
154
|
+
apiKey: string;
|
|
155
|
+
orgId: string;
|
|
156
|
+
amountUsd: number;
|
|
157
|
+
}): Promise<TopUpSession>;
|
|
127
158
|
}
|
|
128
159
|
|
|
129
160
|
// ============================================================================
|
|
@@ -196,9 +227,29 @@ export interface CancelTaskParams {
|
|
|
196
227
|
}
|
|
197
228
|
|
|
198
229
|
export interface ListAgentsParams {
|
|
199
|
-
|
|
230
|
+
tag?: string;
|
|
200
231
|
listing?: 'public' | 'private';
|
|
201
232
|
limit?: number;
|
|
233
|
+
/**
|
|
234
|
+
* Include agents that have no online instances. Defaults to false: the
|
|
235
|
+
* registry returns every registered agent regardless of whether any
|
|
236
|
+
* instance is currently running, so by default we filter to agents with
|
|
237
|
+
* at least one online instance to avoid surfacing unreachable agents.
|
|
238
|
+
*/
|
|
239
|
+
includeOffline?: boolean;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export interface SearchAgentsParams {
|
|
243
|
+
/** Free-text search query; matches agent name, description, tags, etc. */
|
|
244
|
+
query: string;
|
|
245
|
+
tag?: string;
|
|
246
|
+
listing?: 'public' | 'private';
|
|
247
|
+
limit?: number;
|
|
248
|
+
/**
|
|
249
|
+
* Include agents that have no online instances. Defaults to false so we
|
|
250
|
+
* only surface agents that can actually take a task. See ListAgentsParams.
|
|
251
|
+
*/
|
|
252
|
+
includeOffline?: boolean;
|
|
202
253
|
}
|
|
203
254
|
|
|
204
255
|
export interface GetAgentCardParams {
|
|
@@ -210,6 +261,24 @@ export interface ConnectTaskParams {
|
|
|
210
261
|
timeoutMs?: number;
|
|
211
262
|
}
|
|
212
263
|
|
|
264
|
+
export interface DownloadArtifactParams {
|
|
265
|
+
taskId: string;
|
|
266
|
+
fileName: string;
|
|
267
|
+
savePath?: string;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export interface TaskIdParams {
|
|
271
|
+
taskId: string;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export interface GetAgentStatusParams {
|
|
275
|
+
agentNames: string[];
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export interface RequestTopUpParams {
|
|
279
|
+
amountUsd: number;
|
|
280
|
+
}
|
|
281
|
+
|
|
213
282
|
// ============================================================================
|
|
214
283
|
// send_task
|
|
215
284
|
// ============================================================================
|
|
@@ -369,19 +438,108 @@ export async function listAgents(
|
|
|
369
438
|
const result = await deps.listAgents({
|
|
370
439
|
baseUrl,
|
|
371
440
|
apiKey,
|
|
372
|
-
|
|
441
|
+
tag: params.tag,
|
|
373
442
|
listing: params.listing,
|
|
374
|
-
|
|
443
|
+
maxAgents: params.limit,
|
|
375
444
|
});
|
|
376
445
|
|
|
377
|
-
const
|
|
378
|
-
|
|
379
|
-
|
|
446
|
+
const total = result.totalCount ?? result.agents.length;
|
|
447
|
+
|
|
448
|
+
// The registry returns every registered agent regardless of whether any
|
|
449
|
+
// instance is currently running. Default to online-only so we never
|
|
450
|
+
// surface agents that can't actually take a task; `includeOffline` opts
|
|
451
|
+
// back into the full registry view.
|
|
452
|
+
if (params.includeOffline) {
|
|
453
|
+
const lines = result.agents.map(formatAgentRow);
|
|
454
|
+
const header = `Agents (${result.agents.length} of ${total} total):`;
|
|
455
|
+
return { content: [{ type: 'text', text: [header, ...lines].join('\n') }] };
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const online = await filterOnlineAgents(result.agents, { baseUrl, apiKey }, deps);
|
|
459
|
+
const lines = online.map(formatAgentRow);
|
|
460
|
+
const header = `Agents (${online.length} online of ${total} total):`;
|
|
461
|
+
return { content: [{ type: 'text', text: [header, ...lines].join('\n') }] };
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// ============================================================================
|
|
465
|
+
// search_agent
|
|
466
|
+
// ============================================================================
|
|
467
|
+
|
|
468
|
+
export async function searchAgents(
|
|
469
|
+
params: SearchAgentsParams,
|
|
470
|
+
deps: ToolDeps,
|
|
471
|
+
): Promise<ToolResult> {
|
|
472
|
+
const query = params.query.trim();
|
|
473
|
+
if (!query) {
|
|
474
|
+
return {
|
|
475
|
+
content: [{ type: 'text', text: 'Search query must not be empty.' }],
|
|
476
|
+
isError: true,
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const baseUrl = await deps.getBaseUrl();
|
|
481
|
+
const apiKey = deps.getApiKey();
|
|
482
|
+
const result = await deps.listAgents({
|
|
483
|
+
baseUrl,
|
|
484
|
+
apiKey,
|
|
485
|
+
q: query,
|
|
486
|
+
tag: params.tag,
|
|
487
|
+
listing: params.listing,
|
|
488
|
+
maxAgents: params.limit,
|
|
380
489
|
});
|
|
381
|
-
|
|
490
|
+
|
|
491
|
+
const total = result.totalCount ?? result.agents.length;
|
|
492
|
+
|
|
493
|
+
// Mirror list_agents: default to online-only so we never surface agents
|
|
494
|
+
// that can't actually take a task; `includeOffline` opts back into the
|
|
495
|
+
// full set of matches.
|
|
496
|
+
if (params.includeOffline) {
|
|
497
|
+
const lines = result.agents.map(formatAgentRow);
|
|
498
|
+
const header = `Agents matching "${query}" (${result.agents.length} of ${total} total):`;
|
|
499
|
+
return { content: [{ type: 'text', text: [header, ...lines].join('\n') }] };
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const online = await filterOnlineAgents(result.agents, { baseUrl, apiKey }, deps);
|
|
503
|
+
const lines = online.map(formatAgentRow);
|
|
504
|
+
const header = `Agents matching "${query}" (${online.length} online of ${total} total):`;
|
|
382
505
|
return { content: [{ type: 'text', text: [header, ...lines].join('\n') }] };
|
|
383
506
|
}
|
|
384
507
|
|
|
508
|
+
function formatAgentRow(a: ListAgentsResult['agents'][number]): string {
|
|
509
|
+
const tags = a.tags?.map((t) => t.name).join(', ') ?? '';
|
|
510
|
+
return `${a.agentName} | ${a.name ?? a.agentName} | ${a.listing ?? 'public'} | ${tags}`;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Keep only agents that have at least one online instance, as reported by
|
|
515
|
+
* the agent-status service. Agent names that the status endpoint can't
|
|
516
|
+
* accept (too many, or invalid characters) are queried in valid batches;
|
|
517
|
+
* any name that's unqueryable is treated as offline and dropped.
|
|
518
|
+
*/
|
|
519
|
+
async function filterOnlineAgents(
|
|
520
|
+
agents: ListAgentsResult['agents'],
|
|
521
|
+
ctx: { baseUrl: string; apiKey?: string },
|
|
522
|
+
deps: ToolDeps,
|
|
523
|
+
): Promise<ListAgentsResult['agents']> {
|
|
524
|
+
const queryable = agents.filter((a) => AGENT_NAME_PATTERN.test(a.agentName));
|
|
525
|
+
if (queryable.length === 0) return [];
|
|
526
|
+
|
|
527
|
+
const online = new Set<string>();
|
|
528
|
+
for (let i = 0; i < queryable.length; i += MAX_AGENT_NAMES) {
|
|
529
|
+
const batch = queryable.slice(i, i + MAX_AGENT_NAMES);
|
|
530
|
+
const status = await deps.fetchAgentStatus({
|
|
531
|
+
baseUrl: ctx.baseUrl,
|
|
532
|
+
apiKey: ctx.apiKey,
|
|
533
|
+
agentNames: batch.map((a) => a.agentName),
|
|
534
|
+
});
|
|
535
|
+
for (const [name, info] of Object.entries(status.agents)) {
|
|
536
|
+
if ((info.onlineCount ?? 0) > 0) online.add(name);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
return agents.filter((a) => online.has(a.agentName));
|
|
541
|
+
}
|
|
542
|
+
|
|
385
543
|
// ============================================================================
|
|
386
544
|
// get_agent_card
|
|
387
545
|
// ============================================================================
|
|
@@ -487,6 +645,207 @@ export async function connectTask(
|
|
|
487
645
|
}
|
|
488
646
|
}
|
|
489
647
|
|
|
648
|
+
// ============================================================================
|
|
649
|
+
// download_artifact
|
|
650
|
+
// ============================================================================
|
|
651
|
+
|
|
652
|
+
function bytesToBase64(bytes: Uint8Array): string {
|
|
653
|
+
let binary = '';
|
|
654
|
+
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
|
|
655
|
+
return Buffer.from(binary, 'binary').toString('base64');
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
export async function downloadArtifact(
|
|
659
|
+
params: DownloadArtifactParams,
|
|
660
|
+
deps: ToolDeps,
|
|
661
|
+
): Promise<ToolResult> {
|
|
662
|
+
const freeClient = await deps.getTaskClient('free');
|
|
663
|
+
const task = await freeClient.getTask(params.taskId);
|
|
664
|
+
const billingMode = resolveBillingMode(task);
|
|
665
|
+
const client = await deps.getTaskClient(billingMode);
|
|
666
|
+
|
|
667
|
+
let session: TaskSessionLike;
|
|
668
|
+
try {
|
|
669
|
+
session = await client.connect({ taskId: params.taskId });
|
|
670
|
+
} catch (err) {
|
|
671
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
672
|
+
return {
|
|
673
|
+
content: [{ type: 'text', text: `Failed to connect to task ${params.taskId}: ${msg}` }],
|
|
674
|
+
isError: true,
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
try {
|
|
679
|
+
const refs = session.listArtifacts();
|
|
680
|
+
const ref = refs.find((r) => r.fileName === params.fileName);
|
|
681
|
+
if (!ref) {
|
|
682
|
+
const available = refs.map((r) => r.fileName ?? 'unnamed').join(', ') || '(none)';
|
|
683
|
+
return {
|
|
684
|
+
content: [
|
|
685
|
+
{
|
|
686
|
+
type: 'text',
|
|
687
|
+
text: `Artifact "${params.fileName}" not found on task ${params.taskId}. Available: ${available}`,
|
|
688
|
+
},
|
|
689
|
+
],
|
|
690
|
+
isError: true,
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
const downloaded = await session.downloadArtifact(ref);
|
|
695
|
+
|
|
696
|
+
if (params.savePath) {
|
|
697
|
+
const safePath = deps.resolveSavePath(params.savePath);
|
|
698
|
+
deps.writeFile(safePath, downloaded.data);
|
|
699
|
+
return {
|
|
700
|
+
content: [
|
|
701
|
+
{
|
|
702
|
+
type: 'text',
|
|
703
|
+
text: `Saved ${downloaded.data.length} bytes to ${safePath} (${downloaded.mimeType})`,
|
|
704
|
+
},
|
|
705
|
+
],
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
if (isTextMimeType(downloaded.mimeType)) {
|
|
710
|
+
const text = new TextDecoder().decode(downloaded.data);
|
|
711
|
+
return {
|
|
712
|
+
content: [
|
|
713
|
+
{
|
|
714
|
+
type: 'text',
|
|
715
|
+
text: `[artifact: ${downloaded.fileName ?? params.fileName}] (${downloaded.mimeType}, ${downloaded.data.length} bytes)\n${text}`,
|
|
716
|
+
},
|
|
717
|
+
],
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
return {
|
|
722
|
+
content: [
|
|
723
|
+
{
|
|
724
|
+
type: 'text',
|
|
725
|
+
text: `[artifact: ${downloaded.fileName ?? params.fileName}] (${downloaded.mimeType}, ${downloaded.data.length} bytes, base64)\n${bytesToBase64(downloaded.data)}`,
|
|
726
|
+
},
|
|
727
|
+
],
|
|
728
|
+
};
|
|
729
|
+
} finally {
|
|
730
|
+
session.close();
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// ============================================================================
|
|
735
|
+
// pause_task / resume_task / retry_task
|
|
736
|
+
// ============================================================================
|
|
737
|
+
|
|
738
|
+
export async function pauseTask(
|
|
739
|
+
params: TaskIdParams,
|
|
740
|
+
deps: ToolDeps,
|
|
741
|
+
): Promise<ToolResult> {
|
|
742
|
+
const client = await deps.getTaskClient();
|
|
743
|
+
await client.pauseTask(params.taskId);
|
|
744
|
+
return { content: [{ type: 'text', text: `Task ${params.taskId} paused.` }] };
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
export async function resumeTask(
|
|
748
|
+
params: TaskIdParams,
|
|
749
|
+
deps: ToolDeps,
|
|
750
|
+
): Promise<ToolResult> {
|
|
751
|
+
const client = await deps.getTaskClient();
|
|
752
|
+
await client.resumeTask(params.taskId);
|
|
753
|
+
return { content: [{ type: 'text', text: `Task ${params.taskId} resumed.` }] };
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
export async function retryTask(
|
|
757
|
+
params: TaskIdParams,
|
|
758
|
+
deps: ToolDeps,
|
|
759
|
+
): Promise<ToolResult> {
|
|
760
|
+
const client = await deps.getTaskClient();
|
|
761
|
+
await client.retryTask(params.taskId);
|
|
762
|
+
return { content: [{ type: 'text', text: `Task ${params.taskId} retry requested.` }] };
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// ============================================================================
|
|
766
|
+
// get_agent_status
|
|
767
|
+
// ============================================================================
|
|
768
|
+
|
|
769
|
+
export async function getAgentStatus(
|
|
770
|
+
params: GetAgentStatusParams,
|
|
771
|
+
deps: ToolDeps,
|
|
772
|
+
): Promise<ToolResult> {
|
|
773
|
+
const baseUrl = await deps.getBaseUrl();
|
|
774
|
+
const apiKey = deps.getApiKey();
|
|
775
|
+
const result = await deps.fetchAgentStatus({
|
|
776
|
+
baseUrl,
|
|
777
|
+
apiKey,
|
|
778
|
+
agentNames: params.agentNames,
|
|
779
|
+
});
|
|
780
|
+
return {
|
|
781
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// ============================================================================
|
|
786
|
+
// check_balance / request_topup
|
|
787
|
+
// ============================================================================
|
|
788
|
+
|
|
789
|
+
function requireBillingContext(deps: ToolDeps): { apiKey: string; orgId: string } | ToolResult {
|
|
790
|
+
const apiKey = deps.getApiKey();
|
|
791
|
+
if (!apiKey) {
|
|
792
|
+
return {
|
|
793
|
+
content: [{ type: 'text', text: 'BLOCKS_API_KEY is required for billing operations.' }],
|
|
794
|
+
isError: true,
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
const orgId = deps.getOrgId();
|
|
798
|
+
if (!orgId) {
|
|
799
|
+
return {
|
|
800
|
+
content: [
|
|
801
|
+
{
|
|
802
|
+
type: 'text',
|
|
803
|
+
text: 'BLOCKS_ORG_ID is required for billing operations. Set it from your dashboard URL or `blocks whoami --json`.',
|
|
804
|
+
},
|
|
805
|
+
],
|
|
806
|
+
isError: true,
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
return { apiKey, orgId };
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
export async function checkBalance(
|
|
813
|
+
_params: Record<string, never>,
|
|
814
|
+
deps: ToolDeps,
|
|
815
|
+
): Promise<ToolResult> {
|
|
816
|
+
const ctx = requireBillingContext(deps);
|
|
817
|
+
if ('content' in ctx) return ctx;
|
|
818
|
+
const baseUrl = await deps.getBaseUrl();
|
|
819
|
+
const balance = await deps.getConsumerBalance({
|
|
820
|
+
baseUrl,
|
|
821
|
+
apiKey: ctx.apiKey,
|
|
822
|
+
orgId: ctx.orgId,
|
|
823
|
+
});
|
|
824
|
+
return {
|
|
825
|
+
content: [{ type: 'text', text: JSON.stringify(balance, null, 2) }],
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
export async function requestTopup(
|
|
830
|
+
params: RequestTopUpParams,
|
|
831
|
+
deps: ToolDeps,
|
|
832
|
+
): Promise<ToolResult> {
|
|
833
|
+
const ctx = requireBillingContext(deps);
|
|
834
|
+
if ('content' in ctx) return ctx;
|
|
835
|
+
const baseUrl = await deps.getBaseUrl();
|
|
836
|
+
const session = await deps.createConsumerTopUp({
|
|
837
|
+
baseUrl,
|
|
838
|
+
apiKey: ctx.apiKey,
|
|
839
|
+
orgId: ctx.orgId,
|
|
840
|
+
amountUsd: params.amountUsd,
|
|
841
|
+
});
|
|
842
|
+
const url = session.checkoutUrl;
|
|
843
|
+
const body = url
|
|
844
|
+
? `Open this URL in a browser to complete the $${params.amountUsd.toFixed(2)} top-up:\n${url}`
|
|
845
|
+
: JSON.stringify(session, null, 2);
|
|
846
|
+
return { content: [{ type: 'text', text: body }] };
|
|
847
|
+
}
|
|
848
|
+
|
|
490
849
|
// ============================================================================
|
|
491
850
|
// Default file helpers (re-exported so index.ts can build a default ToolDeps)
|
|
492
851
|
// ============================================================================
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { checkBalance, requestTopup } from '../src/tools.js';
|
|
3
|
+
import { getConsumerBalance, createConsumerTopUp } from '../src/billing.js';
|
|
4
|
+
import {
|
|
5
|
+
PROTOCOL_VERSION_HEADER,
|
|
6
|
+
CURRENT_PROTOCOL_VERSION,
|
|
7
|
+
} from '../src/protocol-headers.js';
|
|
8
|
+
import { makeFakeDeps } from './helpers.js';
|
|
9
|
+
|
|
10
|
+
function mockResponse(body: unknown, ok = true, status = 200): Response {
|
|
11
|
+
return {
|
|
12
|
+
ok,
|
|
13
|
+
status,
|
|
14
|
+
json: async () => body,
|
|
15
|
+
text: async () => JSON.stringify(body),
|
|
16
|
+
} as Response;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('check_balance (handler)', () => {
|
|
20
|
+
it('returns an error when BLOCKS_API_KEY is missing', async () => {
|
|
21
|
+
const { deps } = makeFakeDeps();
|
|
22
|
+
|
|
23
|
+
const res = await checkBalance({}, deps);
|
|
24
|
+
|
|
25
|
+
expect(res.isError).toBe(true);
|
|
26
|
+
expect(res.content[0].text).toMatch(/BLOCKS_API_KEY/);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('returns an error when BLOCKS_ORG_ID is missing', async () => {
|
|
30
|
+
const { deps } = makeFakeDeps({ apiKey: 'bk_test' });
|
|
31
|
+
|
|
32
|
+
const res = await checkBalance({}, deps);
|
|
33
|
+
|
|
34
|
+
expect(res.isError).toBe(true);
|
|
35
|
+
expect(res.content[0].text).toMatch(/BLOCKS_ORG_ID/);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('forwards baseUrl, orgId, apiKey and emits balance JSON', async () => {
|
|
39
|
+
const balance = {
|
|
40
|
+
balance: '100.00',
|
|
41
|
+
reservedBalance: '5.00',
|
|
42
|
+
availableBalance: '95.00',
|
|
43
|
+
updatedAt: '2026-05-28T00:00:00Z',
|
|
44
|
+
};
|
|
45
|
+
const { deps, mocks } = makeFakeDeps({
|
|
46
|
+
apiKey: 'bk_test',
|
|
47
|
+
orgId: 'org_42',
|
|
48
|
+
balance,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const res = await checkBalance({}, deps);
|
|
52
|
+
|
|
53
|
+
expect(mocks.getConsumerBalance).toHaveBeenCalledWith({
|
|
54
|
+
baseUrl: 'http://api.test',
|
|
55
|
+
apiKey: 'bk_test',
|
|
56
|
+
orgId: 'org_42',
|
|
57
|
+
});
|
|
58
|
+
expect(JSON.parse(res.content[0].text)).toEqual(balance);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('request_topup (handler)', () => {
|
|
63
|
+
it('returns an error when BLOCKS_ORG_ID is missing', async () => {
|
|
64
|
+
const { deps } = makeFakeDeps({ apiKey: 'bk_test' });
|
|
65
|
+
|
|
66
|
+
const res = await requestTopup({ amountUsd: 25 }, deps);
|
|
67
|
+
|
|
68
|
+
expect(res.isError).toBe(true);
|
|
69
|
+
expect(res.content[0].text).toMatch(/BLOCKS_ORG_ID/);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('returns the Stripe checkout URL with a human-readable label', async () => {
|
|
73
|
+
const { deps, mocks } = makeFakeDeps({
|
|
74
|
+
apiKey: 'bk_test',
|
|
75
|
+
orgId: 'org_42',
|
|
76
|
+
topUpSession: {
|
|
77
|
+
checkoutUrl: 'https://checkout.stripe.test/session/xyz',
|
|
78
|
+
sessionId: 'cs_test_abc',
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const res = await requestTopup({ amountUsd: 25 }, deps);
|
|
83
|
+
|
|
84
|
+
expect(mocks.createConsumerTopUp).toHaveBeenCalledWith({
|
|
85
|
+
baseUrl: 'http://api.test',
|
|
86
|
+
apiKey: 'bk_test',
|
|
87
|
+
orgId: 'org_42',
|
|
88
|
+
amountUsd: 25,
|
|
89
|
+
});
|
|
90
|
+
expect(res.content[0].text).toContain('https://checkout.stripe.test/session/xyz');
|
|
91
|
+
expect(res.content[0].text).toContain('$25.00');
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('getConsumerBalance (HTTP helper)', () => {
|
|
96
|
+
it('GETs /api/v1/billing/:orgId/consumer/balance with Bearer auth', async () => {
|
|
97
|
+
const fetchImpl = vi
|
|
98
|
+
.fn()
|
|
99
|
+
.mockResolvedValue(
|
|
100
|
+
mockResponse({
|
|
101
|
+
balance: '0',
|
|
102
|
+
reservedBalance: '0',
|
|
103
|
+
availableBalance: '0',
|
|
104
|
+
updatedAt: '2026-05-28T00:00:00Z',
|
|
105
|
+
}),
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
await getConsumerBalance({
|
|
109
|
+
baseUrl: 'http://api.test',
|
|
110
|
+
orgId: 'org_42',
|
|
111
|
+
apiKey: 'bk_test',
|
|
112
|
+
fetchImpl: fetchImpl as unknown as typeof fetch,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const [url, init] = fetchImpl.mock.calls[0];
|
|
116
|
+
const parsed = new URL(String(url));
|
|
117
|
+
expect(parsed.pathname).toBe('/api/v1/billing/org_42/consumer/balance');
|
|
118
|
+
expect(init?.method).toBe('GET');
|
|
119
|
+
const headers = init?.headers as Record<string, string>;
|
|
120
|
+
expect(headers.Authorization).toBe('Bearer bk_test');
|
|
121
|
+
expect(headers[PROTOCOL_VERSION_HEADER]).toBe(CURRENT_PROTOCOL_VERSION);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('throws on non-OK responses', async () => {
|
|
125
|
+
const fetchImpl = vi.fn().mockResolvedValue(mockResponse({}, false, 403));
|
|
126
|
+
|
|
127
|
+
await expect(
|
|
128
|
+
getConsumerBalance({
|
|
129
|
+
baseUrl: 'http://api.test',
|
|
130
|
+
orgId: 'org_42',
|
|
131
|
+
apiKey: 'bk_test',
|
|
132
|
+
fetchImpl: fetchImpl as unknown as typeof fetch,
|
|
133
|
+
}),
|
|
134
|
+
).rejects.toThrow('HTTP 403');
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe('createConsumerTopUp (HTTP helper)', () => {
|
|
139
|
+
it('POSTs JSON body with whole-cent amount string', async () => {
|
|
140
|
+
const fetchImpl = vi
|
|
141
|
+
.fn()
|
|
142
|
+
.mockResolvedValue(
|
|
143
|
+
mockResponse({
|
|
144
|
+
checkoutUrl: 'https://checkout.stripe.test/x',
|
|
145
|
+
sessionId: 'cs_test_x',
|
|
146
|
+
}),
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
await createConsumerTopUp({
|
|
150
|
+
baseUrl: 'http://api.test',
|
|
151
|
+
orgId: 'org_42',
|
|
152
|
+
apiKey: 'bk_test',
|
|
153
|
+
amountUsd: 19.99,
|
|
154
|
+
fetchImpl: fetchImpl as unknown as typeof fetch,
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const [url, init] = fetchImpl.mock.calls[0];
|
|
158
|
+
const parsed = new URL(String(url));
|
|
159
|
+
expect(parsed.pathname).toBe('/api/v1/billing/org_42/consumer/topup');
|
|
160
|
+
expect(init?.method).toBe('POST');
|
|
161
|
+
expect(JSON.parse(String(init?.body))).toEqual({ amount: '19.99' });
|
|
162
|
+
const headers = init?.headers as Record<string, string>;
|
|
163
|
+
expect(headers['Content-Type']).toBe('application/json');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('rejects non-positive amounts', async () => {
|
|
167
|
+
await expect(
|
|
168
|
+
createConsumerTopUp({
|
|
169
|
+
baseUrl: 'http://api.test',
|
|
170
|
+
orgId: 'org_42',
|
|
171
|
+
apiKey: 'bk_test',
|
|
172
|
+
amountUsd: 0,
|
|
173
|
+
}),
|
|
174
|
+
).rejects.toThrow(/positive/);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('rejects amounts below the $5 platform minimum', async () => {
|
|
178
|
+
await expect(
|
|
179
|
+
createConsumerTopUp({
|
|
180
|
+
baseUrl: 'http://api.test',
|
|
181
|
+
orgId: 'org_42',
|
|
182
|
+
apiKey: 'bk_test',
|
|
183
|
+
amountUsd: 1,
|
|
184
|
+
}),
|
|
185
|
+
).rejects.toThrow(/at least \$5/);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('throws on non-OK responses with body detail', async () => {
|
|
189
|
+
const fetchImpl = vi
|
|
190
|
+
.fn()
|
|
191
|
+
.mockResolvedValue(mockResponse({ error: 'amount too small' }, false, 400));
|
|
192
|
+
|
|
193
|
+
await expect(
|
|
194
|
+
createConsumerTopUp({
|
|
195
|
+
baseUrl: 'http://api.test',
|
|
196
|
+
orgId: 'org_42',
|
|
197
|
+
apiKey: 'bk_test',
|
|
198
|
+
amountUsd: 25,
|
|
199
|
+
fetchImpl: fetchImpl as unknown as typeof fetch,
|
|
200
|
+
}),
|
|
201
|
+
).rejects.toThrow(/HTTP 400/);
|
|
202
|
+
});
|
|
203
|
+
});
|